diff --git a/.github/scripts/setup-android-sdk.sh b/.github/scripts/setup-android-sdk.sh index 8ca3a061..7c7a88aa 100755 --- a/.github/scripts/setup-android-sdk.sh +++ b/.github/scripts/setup-android-sdk.sh @@ -4,7 +4,7 @@ set -euo pipefail ANDROID_API="${ANDROID_API:-34}" -ANDROID_NDK_VERSION="${ANDROID_NDK_VERSION:-26.1.10909125}" +ANDROID_NDK_VERSION="${ANDROID_NDK_VERSION:-27.3.13750724}" to_unix() { local p="${1//\\//}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de264f10..93116150 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,9 @@ env: # Single source of truth: repository variables (Settings -> Secrets and # variables -> Actions -> Variables), falling back to these defaults. VULKAN_VERSION: ${{ vars.VULKAN_VERSION || '1.4.304.1' }} - ANDROID_NDK_VERSION: ${{ vars.ANDROID_NDK_VERSION || '26.1.10909125' }} + # Aligned to the NDK preinstalled on the windows-latest / macos-latest runner + # images (27.3.13750724), so setup-android downloads nothing extra. + ANDROID_NDK_VERSION: ${{ vars.ANDROID_NDK_VERSION || '27.3.13750724' }} ANDROID_API: ${{ vars.ANDROID_API || '34' }} jobs: @@ -264,6 +266,9 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: evengine-cpp-v1-windows-x64 + # Pull-request cache entries are scoped to refs/pull/*/merge and cannot + # warm other PRs. Let PRs consume the dev cache without duplicating it. + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} SCCACHE_DIRECT: "false" defaults: run: @@ -328,6 +333,7 @@ jobs: # VK_KHR_surface so SDL_WINDOW_VULKAN / unit tests can run. install_swiftshader: true cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (SwiftShader) @@ -357,7 +363,9 @@ jobs: run: bash .github/scripts/verify-third-party-cache.sh build/third-party-binary/win32-debug win32 Debug - name: Save win32 third-party cache - if: steps.third-party-cache.outputs.cache-hit != 'true' + if: >- + steps.third-party-cache.outputs.cache-hit != 'true' && + (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') timeout-minutes: 15 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: @@ -385,6 +393,7 @@ jobs: cmake\with-msvc.cmd cmake.exe -S . -B build/webgpu-native -G Ninja ` -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl ` -DBUILD_PLATFORM=webgpu -DBUILD_TESTING=ON ` + -DEVENGINE_COMPILER_CACHE=SCCACHE ` -DEVENGINE_THIRD_PARTY_BINARY_DIR="$PWD/build/third-party-binary/win32-debug" ` -DEVENGINE_DOWNLOAD_SPINE_MODELS=OFF ` -DEVENGINE_DOWNLOAD_SKINNED_CHARACTER=OFF @@ -394,16 +403,24 @@ jobs: shell: pwsh run: cmake\with-msvc.cmd cmake.exe --build build/webgpu-native --target unit_test -j 8 - - name: Capture native Dawn parity artifacts + - name: Report native Dawn compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats + + - name: Run native Dawn parity and PCG compute # Dawn initialization takes roughly two minutes per isolated test on # the Windows software adapter. Keep per-case process isolation while - # allowing all 13 parity cases to complete. + # allowing all parity cases plus the PCG compute parity case to complete. timeout-minutes: 40 shell: pwsh env: ALSOFT_DRIVERS: "null" EVENGINE_RENDER_PARITY_DIR: ${{ github.workspace }}/artifacts/render-parity - run: ctest --test-dir build/webgpu-native -R '^graphics\.backendParity\.' --output-on-failure + run: >- + ctest --test-dir build/webgpu-native + -R '^(graphics\.backendParity\..*|gpgpu\.procgen\.transformParity)$' + --output-on-failure - name: Compare Vulkan and WebGPU parity images timeout-minutes: 5 @@ -426,6 +443,11 @@ jobs: if: ${{ inputs.build_type == 'release' || inputs.build_type == 'both' }} runs-on: windows-latest timeout-minutes: 60 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-windows-x64-release + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" defaults: run: shell: bash @@ -444,6 +466,12 @@ jobs: choco install make ninja -y --no-progress echo "VS_GENERATOR=Visual Studio 17 2022" >> $env:GITHUB_ENV + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Install Vulkan SDK timeout-minutes: 20 uses: jakoch/install-vulkan-sdk-action@37effcfa045411f8bfbbda26df2fd1b3bf3436fa # v1 @@ -452,6 +480,7 @@ jobs: install_runtime: true install_swiftshader: true cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (SwiftShader) @@ -462,7 +491,14 @@ jobs: timeout-minutes: 40 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: make build/win32 JOBS="${NUMBER_OF_PROCESSORS:-8}" + run: >- + make build/win32 JOBS="${NUMBER_OF_PROCESSORS:-8}" + CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE" + + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats - name: Run win32 release unit tests timeout-minutes: 30 @@ -475,6 +511,11 @@ jobs: if: ${{ github.event_name != 'workflow_call' || inputs.build_type == 'debug' || inputs.build_type == 'both' }} runs-on: windows-latest timeout-minutes: 60 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-android-arm64-debug + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" defaults: run: shell: bash @@ -491,6 +532,12 @@ jobs: shell: pwsh run: choco install make ninja -y --no-progress + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Setup Java 17 timeout-minutes: 10 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 @@ -528,13 +575,24 @@ jobs: ANDROID_SDK="$ANDROID_SDK" \ ANDROID_NDK="$ANDROID_NDK" \ JAVA_HOME="$JAVA_HOME" \ + CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE" \ ANDROID_JOBS="${NUMBER_OF_PROCESSORS:-8}" + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats + android-release: name: Android (Release) if: ${{ inputs.build_type == 'release' || inputs.build_type == 'both' }} runs-on: windows-latest timeout-minutes: 60 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-android-arm64-release + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" defaults: run: shell: bash @@ -551,6 +609,12 @@ jobs: shell: pwsh run: choco install make ninja -y --no-progress + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Setup Java 17 timeout-minutes: 10 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 @@ -586,13 +650,24 @@ jobs: ANDROID_SDK="$ANDROID_SDK" \ ANDROID_NDK="$ANDROID_NDK" \ JAVA_HOME="$JAVA_HOME" \ + CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE" \ ANDROID_JOBS="${NUMBER_OF_PROCESSORS:-8}" + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats + macos: name: macOS if: ${{ github.event_name != 'workflow_call' || inputs.build_type == 'debug' || inputs.build_type == 'both' }} runs-on: macos-latest timeout-minutes: 120 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-macos-arm64-debug + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" steps: - name: Checkout timeout-minutes: 10 @@ -605,12 +680,19 @@ jobs: timeout-minutes: 10 run: brew install ninja || true + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Install Vulkan SDK (MoltenVK + iOS) timeout-minutes: 20 uses: jakoch/install-vulkan-sdk-action@37effcfa045411f8bfbbda26df2fd1b3bf3436fa # v1 with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} destination: ${{ runner.temp }}/VulkanSDK github_token: ${{ secrets.GITHUB_TOKEN }} @@ -622,7 +704,9 @@ jobs: timeout-minutes: 40 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: make build/macosx-debug JOBS="$(sysctl -n hw.ncpu)" + run: >- + make build/macosx-debug JOBS="$(sysctl -n hw.ncpu)" + CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE" - name: Run macosx unit tests timeout-minutes: 30 @@ -636,6 +720,7 @@ jobs: cmake -S . -B build/webgpu-macos -G Ninja \ -DCMAKE_BUILD_TYPE=Debug \ -DBUILD_PLATFORM=webgpu -DBUILD_TESTING=ON \ + -DEVENGINE_COMPILER_CACHE=SCCACHE \ -DEVENGINE_THIRD_PARTY_BINARY_DIR="$PWD/build/third-party-binary/macosx-debug" \ -DEVENGINE_DOWNLOAD_SPINE_MODELS=OFF \ -DEVENGINE_DOWNLOAD_SKINNED_CHARACTER=OFF @@ -644,6 +729,11 @@ jobs: timeout-minutes: 40 run: cmake --build build/webgpu-macos --target unit_test -j "$(sysctl -n hw.ncpu)" + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats + - name: Test native Dawn Metal surface and rendering timeout-minutes: 20 env: @@ -658,6 +748,11 @@ jobs: if: ${{ inputs.build_type == 'release' || inputs.build_type == 'both' }} runs-on: macos-latest timeout-minutes: 60 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-macos-arm64-release + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" steps: - name: Checkout timeout-minutes: 10 @@ -670,12 +765,19 @@ jobs: timeout-minutes: 10 run: brew install ninja || true + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Install Vulkan SDK (MoltenVK + iOS) timeout-minutes: 20 uses: jakoch/install-vulkan-sdk-action@37effcfa045411f8bfbbda26df2fd1b3bf3436fa # v1 with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} destination: ${{ runner.temp }}/VulkanSDK github_token: ${{ secrets.GITHUB_TOKEN }} @@ -687,7 +789,14 @@ jobs: timeout-minutes: 40 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: make build/macosx JOBS="$(sysctl -n hw.ncpu)" + run: >- + make build/macosx JOBS="$(sysctl -n hw.ncpu)" + CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE" + + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats - name: Run macosx release unit tests timeout-minutes: 30 @@ -718,6 +827,7 @@ jobs: with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} destination: ${{ runner.temp }}/VulkanSDK github_token: ${{ secrets.GITHUB_TOKEN }} @@ -774,6 +884,7 @@ jobs: with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} destination: ${{ runner.temp }}/VulkanSDK github_token: ${{ secrets.GITHUB_TOKEN }} @@ -815,6 +926,9 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64 + # Populate shared caches only from long-lived branches; PR-scoped writes + # are dead stores after that PR closes and quickly evict reusable entries. + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} SCCACHE_DIRECT: "false" steps: - name: Checkout @@ -887,6 +1001,7 @@ jobs: with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (Lavapipe) @@ -916,7 +1031,9 @@ jobs: run: bash .github/scripts/verify-third-party-cache.sh build/third-party-binary/linux-debug linux Release - name: Save linux third-party cache - if: steps.third-party-cache.outputs.cache-hit != 'true' + if: >- + steps.third-party-cache.outputs.cache-hit != 'true' && + (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') timeout-minutes: 15 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: @@ -965,6 +1082,11 @@ jobs: if: ${{ inputs.build_type == 'release' || inputs.build_type == 'both' }} runs-on: ubuntu-24.04 timeout-minutes: 60 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64-release + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" steps: - name: Checkout timeout-minutes: 10 @@ -1002,12 +1124,19 @@ jobs: mesa-vulkan-drivers vulkan-tools \ xvfb + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Install Vulkan SDK timeout-minutes: 20 uses: jakoch/install-vulkan-sdk-action@37effcfa045411f8bfbbda26df2fd1b3bf3436fa # v1 with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (Lavapipe) @@ -1018,7 +1147,14 @@ jobs: timeout-minutes: 40 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: make build/linux JOBS="$(nproc)" + run: >- + make build/linux JOBS="$(nproc)" + CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE" + + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats - name: Run linux release unit tests timeout-minutes: 30 @@ -1037,6 +1173,10 @@ jobs: # the sanitizer job's value is UAF/OOB/UB, not leaks. ASAN_OPTIONS: "detect_leaks=0:halt_on_error=1" UBSAN_OPTIONS: "halt_on_error=1:print_stacktrace=1" + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64-asan + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" steps: - name: Checkout timeout-minutes: 10 @@ -1071,12 +1211,19 @@ jobs: mesa-vulkan-drivers vulkan-tools \ xvfb + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Install Vulkan SDK timeout-minutes: 20 uses: jakoch/install-vulkan-sdk-action@37effcfa045411f8bfbbda26df2fd1b3bf3436fa # v1 with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (Lavapipe) @@ -1087,7 +1234,14 @@ jobs: timeout-minutes: 40 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: make build/linux-asan JOBS="$(nproc)" + run: >- + make build/linux-asan JOBS="$(nproc)" + CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE" + + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats - name: Run linux unit tests (ASan+UBSan) timeout-minutes: 30 @@ -1103,6 +1257,11 @@ jobs: if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-24.04 timeout-minutes: 60 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64-coverage + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" steps: - name: Checkout timeout-minutes: 10 @@ -1136,12 +1295,19 @@ jobs: mesa-vulkan-drivers vulkan-tools \ xvfb gcovr + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Install Vulkan SDK timeout-minutes: 20 uses: jakoch/install-vulkan-sdk-action@37effcfa045411f8bfbbda26df2fd1b3bf3436fa # v1 with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }} github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (Lavapipe) @@ -1152,7 +1318,14 @@ jobs: timeout-minutes: 40 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: make build/linux-coverage JOBS="$(nproc)" + run: >- + make build/linux-coverage JOBS="$(nproc)" + CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE" + + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats - name: Run linux unit tests (coverage) timeout-minutes: 30 @@ -1196,6 +1369,10 @@ jobs: UBSAN_OPTIONS: "halt_on_error=1:print_stacktrace=1" EVENGINE_FUZZ_RUNS: ${{ github.event_name == 'schedule' && '50000' || '2000' }} EVENGINE_FUZZ_SEED: "1" + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64-fuzz + SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }} + SCCACHE_DIRECT: "false" steps: - name: Checkout timeout-minutes: 10 @@ -1210,9 +1387,22 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends clang ninja-build + - name: Setup C/C++ compilation cache + timeout-minutes: 10 + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: "v0.16.0" + - name: Build zeroerr JSON fuzz harness timeout-minutes: 5 - run: make build/linux-fuzz JOBS="$(nproc)" + run: >- + make build/linux-fuzz JOBS="$(nproc)" + CMAKE_EXTRA_ARGS="-DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache" + + - name: Report C/C++ compilation cache + if: always() + timeout-minutes: 5 + run: sccache --show-stats - name: Fuzz shared JSON parser timeout-minutes: 5 @@ -1234,6 +1424,9 @@ jobs: name: WebGPU (WASM) runs-on: ubuntu-24.04 timeout-minutes: 60 + # Do not add sccache via EM_COMPILER_WRAPPER here: emsdk's Clang commands + # carry -Xclang flags that sccache v0.16 treats as non-cacheable, adding + # wrapper overhead without storing any of the engine's object files. steps: - name: Checkout timeout-minutes: 10 @@ -1270,10 +1463,22 @@ jobs: python3 -m pip install --user cmake==3.29.6 echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Restore Emscripten SDK cache + id: emsdk-cache + timeout-minutes: 5 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: $HOME/emsdk + key: emsdk-${{ runner.os }}-${{ runner.arch }}-latest + - name: Install Emscripten SDK timeout-minutes: 30 run: | - git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$HOME/emsdk" + if [ "${{ steps.emsdk-cache.outputs.cache-hit }}" = "true" ]; then + echo "Using cached Emscripten SDK" + else + git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$HOME/emsdk" + fi "$HOME/emsdk/emsdk" install latest "$HOME/emsdk/emsdk" activate latest @@ -1361,3 +1566,13 @@ jobs: build/webgpu-web/src/engine/webgpu-browser.png build/webgpu-web/src/engine/webgpu-http.log if-no-files-found: warn + + - name: Save Emscripten SDK cache + # Only refresh on the default branch so per-PR runs restore (free) but + # never rewrite the cache, keeping churn/eviction of hot caches low. + if: ${{ steps.emsdk-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/dev' }} + timeout-minutes: 15 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: $HOME/emsdk + key: ${{ steps.emsdk-cache.outputs.cache-primary-key }} diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml index fb65c7ef..5687e267 100644 --- a/.github/workflows/sdk-release.yml +++ b/.github/workflows/sdk-release.yml @@ -41,7 +41,9 @@ env: # Single source of truth: repository variables (Settings -> Secrets and # variables -> Actions -> Variables), falling back to these defaults. VULKAN_VERSION: ${{ vars.VULKAN_VERSION || '1.4.304.1' }} - ANDROID_NDK_VERSION: ${{ vars.ANDROID_NDK_VERSION || '26.1.10909125' }} + # Aligned to the NDK preinstalled on the windows-latest / macos-latest runner + # images (27.3.13750724), so setup-android downloads nothing extra. + ANDROID_NDK_VERSION: ${{ vars.ANDROID_NDK_VERSION || '27.3.13750724' }} ANDROID_API: ${{ vars.ANDROID_API || '34' }} jobs: @@ -75,6 +77,9 @@ jobs: vulkan_version: ${{ env.VULKAN_VERSION }} install_runtime: true cache: true + # Release refs are one-shot cache scopes. Restore a long-lived cache + # when available, but do not create a copy that no later run can use. + cache_save_if: false github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan (SwiftShader, best-effort) @@ -220,6 +225,7 @@ jobs: vulkan_version: ${{ env.VULKAN_VERSION }} # Keep full macOS tree so MoltenVK remains available. cache: true + cache_save_if: false destination: ${{ runner.temp }}/VulkanSDK github_token: ${{ secrets.GITHUB_TOKEN }} @@ -280,6 +286,7 @@ jobs: vulkan_version: ${{ env.VULKAN_VERSION }} # Keep full macOS tree so MoltenVK / iOS xcframework remain available. cache: true + cache_save_if: false destination: ${{ runner.temp }}/VulkanSDK github_token: ${{ secrets.GITHUB_TOKEN }} @@ -380,6 +387,7 @@ jobs: with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: false github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (Lavapipe, best-effort) @@ -545,6 +553,7 @@ jobs: install_runtime: true install_swiftshader: true cache: true + cache_save_if: false github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (SwiftShader) @@ -728,6 +737,7 @@ jobs: install_runtime: true install_swiftshader: true cache: true + cache_save_if: false github_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup software Vulkan ICD (SwiftShader) @@ -902,6 +912,7 @@ jobs: with: vulkan_version: ${{ env.VULKAN_VERSION }} cache: true + cache_save_if: false destination: ${{ runner.temp }}/VulkanSDK github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CMakeLists.txt b/CMakeLists.txt index 6af07db1..fabe278e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1142,6 +1142,28 @@ function(check_third_party_project name repo) -DPATCH=${CMAKE_SOURCE_DIR}/cmake/patches/mpg123-signal-handler.patch -DPATCH_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${name}/medialoader -P ${CMAKE_SOURCE_DIR}/cmake/patch_third_party.cmake) + # Sixth patch: SDL2's Android sensor loop uses ALooper_pollAll, which the + # NDK 27+ headers mark unavailable (obsoleted in Android 1). Use the + # equivalent ALooper_pollOnce (same non-blocking semantics at timeout 0). + # This is upstream SDL's own fix for libsdl-org/SDL#9792 (the exact NDK-27 + # build failure): commit 558630d "Use ALooper_pollOnce() instead of + # ALooper_pollAll()" notes the existing draining loop already handles all + # events, so no events are lost. SDL2 2.0.x never adopted it, hence the patch. + # The sensor looper is dedicated (not the Android UI message loop), so this + # cannot affect the main UI looper. + set(_eve_tp_patch_cmd ${_eve_tp_patch_cmd} + COMMAND ${CMAKE_COMMAND} + -DPATCH=${CMAKE_SOURCE_DIR}/cmake/patches/sdl2-android-alooper-pollonce.patch + -DPATCH_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${name} + -P ${CMAKE_SOURCE_DIR}/cmake/patch_third_party.cmake) + # Seventh patch: Binding Contract constraints describe the argument as a + # whole. Consume them at the expression's first factor so a string result + # does not reject numeric literals nested in concatenations or ternaries. + set(_eve_tp_patch_cmd ${_eve_tp_patch_cmd} + COMMAND ${CMAKE_COMMAND} + -DPATCH=${CMAKE_SOURCE_DIR}/cmake/patches/squirrel-binding-contract-expression-scope.patch + -DPATCH_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${name} + -P ${CMAKE_SOURCE_DIR}/cmake/patch_third_party.cmake) # Stamp the git versions into the install tree after every install so # prebuilt-mode consumers (and eve's build info) can report exactly which diff --git a/Makefile b/Makefile index 56840451..26593318 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ else ifeq ($(PLATFORM),win32) else ANDROID_SDK ?= $(HOME)/Android/Sdk endif -ANDROID_NDK ?= $(ANDROID_SDK)/ndk/26.1.10909125 +ANDROID_NDK ?= $(ANDROID_SDK)/ndk/27.3.13750724 ifeq ($(wildcard $(ANDROID_NDK)/build/cmake/android.toolchain.cmake),) ANDROID_NDK := $(shell ls -d "$(ANDROID_SDK)/ndk"/* 2>/dev/null | sort -V | tail -1) endif @@ -433,6 +433,7 @@ build/linux-fuzz: build/linux-fuzz/build.ninja build/linux-fuzz/build.ninja: cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + $(CMAKE_EXTRA_ARGS) \ -B build/linux-fuzz -S test/fuzz fuzz/linux: build/linux-fuzz diff --git a/Readme.en.md b/Readme.en.md index f8f8e58d..17978aab 100644 --- a/Readme.en.md +++ b/Readme.en.md @@ -103,7 +103,7 @@ bin\eve.exe run mygame ### What you can do with the engine -- **Make 2D / third-person 3D / mixed 2D+3D games**: 40+ modules — tilemap, cameras, Box2D/Box3D physics, particles, animation, RPG, inventory, UI, procedural generation, and more — all driven from Squirrel scripts. See the [module handbook](docs/usr/MODULES.md). +- **Make 2D / third-person 3D / mixed 2D+3D games**: 40+ modules — tilemap, cameras, Box2D/Box3D physics, particles, animation, RPG, inventory, UI, procedural generation, and more — all driven by EveScript (Squirrel-compatible). Start with the [EveScript tutorial](docs/usr/EVESCRIPT.md), then use the [module handbook](docs/usr/MODULES.md). - **Hot reload for scripts and assets**: on desktop, edits take effect immediately; on mobile, push changes to the device with the `eve dev` dev server — no reinstall. - **Debug**: pause the game loop, breakpoints, watches, snapshots; VS Code debug adapter and AI (MCP) assisted development. - **Package and publish**: `eve zip` compresses a game into a `.eve` archive; `eve package --sdk ` produces a distributable folder containing the runtime and your game; Android/iOS use the SDK's bundled templates to assemble APK / .app. @@ -200,7 +200,7 @@ ls "$VULKAN_SDK/lib/libvulkan.dylib" "$VULKAN_SDK/lib/libMoltenVK.dylib" | Host | macOS / Linux (Makefile paths default to macOS Homebrew) | | JDK | **OpenJDK 17** (`brew install openjdk@17`) | | Android SDK | command-line tools + `platform-tools` + `platforms;android-34` + `build-tools;34.0.0` | -| NDK | **26.1.10909125** (`sdkmanager "ndk;26.1.10909125"`) | +| NDK | **27.3.13750724** (`sdkmanager "ndk;27.3.13750724"`) | | CMake (SDK) | `cmake;3.22.1` (for Gradle; the engine itself cross-compiles with host CMake/Ninja) | | ABI / minSdk | **arm64-v8a** / **24** | | Device | Physical device with **Vulkan** (emulator is not a v1 acceptance target) | @@ -210,7 +210,7 @@ Environment variables (add to `~/.zshrc` or export before building): ```sh export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" export ANDROID_HOME="$HOME/Library/Android/sdk" -export ANDROID_NDK="$ANDROID_HOME/ndk/26.1.10909125" +export ANDROID_NDK="$ANDROID_HOME/ndk/27.3.13750724" export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" ``` diff --git a/Readme.md b/Readme.md index 4c2fed91..14bf8b47 100644 --- a/Readme.md +++ b/Readme.md @@ -103,7 +103,7 @@ bin\eve.exe run mygame ### 拿到引擎后,你可以做这些事 -- **制作 2D / 第三人称 3D / 2D+3D 混合游戏**:Tilemap、摄像机、Box2D/Box3D 物理、粒子、动画、RPG、背包、UI、程序化生成等 40+ 模块,全部通过 Squirrel 脚本调用,逐个模块的手册见[模块使用手册](docs/usr/MODULES.md)。 +- **制作 2D / 第三人称 3D / 2D+3D 混合游戏**:Tilemap、摄像机、Box2D/Box3D 物理、粒子、动画、RPG、背包、UI、程序化生成等 40+ 模块,全部通过 EveScript(兼容 Squirrel)调用;先阅读[EveScript 完整教程](docs/usr/EVESCRIPT.md),逐个模块的接口见[模块使用手册](docs/usr/MODULES.md)。 - **脚本与资源热重载**:桌面端改完脚本/贴图立即生效;移动端用 `eve dev` 开发服务器把改动推送到真机,无需重装应用。 - **调试**:暂停游戏循环、断点、监视、快照,支持 VS Code 调试适配器与 AI(MCP)辅助开发。 - **打包发布**:`eve zip` 把游戏压缩为 `.eve` 归档;`eve package --sdk ` 生成包含运行时与游戏的可分发目录;Android / iOS 用 SDK 自带模板组装 APK / .app。 @@ -200,7 +200,7 @@ ls "$VULKAN_SDK/lib/libvulkan.dylib" "$VULKAN_SDK/lib/libMoltenVK.dylib" | 宿主 | macOS / Linux(本仓库 Makefile 默认路径面向 macOS Homebrew) | | JDK | **OpenJDK 17**(`brew install openjdk@17`) | | Android SDK | command-line tools + `platform-tools` + `platforms;android-34` + `build-tools;34.0.0` | -| NDK | **26.1.10909125**(`sdkmanager "ndk;26.1.10909125"`) | +| NDK | **27.3.13750724**(`sdkmanager "ndk;27.3.13750724"`) | | CMake(SDK) | `cmake;3.22.1`(Gradle 可用;引擎本体用宿主机 CMake/Ninja 交叉编译) | | ABI / minSdk | **arm64-v8a** / **24** | | 设备 | 支持 **Vulkan** 的真机(模拟器未作为首版验收目标) | @@ -210,7 +210,7 @@ ls "$VULKAN_SDK/lib/libvulkan.dylib" "$VULKAN_SDK/lib/libMoltenVK.dylib" ```sh export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" export ANDROID_HOME="$HOME/Library/Android/sdk" -export ANDROID_NDK="$ANDROID_HOME/ndk/26.1.10909125" +export ANDROID_NDK="$ANDROID_HOME/ndk/27.3.13750724" export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" ``` diff --git a/cmake/module_manifest.cmake b/cmake/module_manifest.cmake index 4c755cbf..6f1bb70a 100644 --- a/cmake/module_manifest.cmake +++ b/cmake/module_manifest.cmake @@ -84,6 +84,13 @@ eve_declare_module(NAME timer REQUIRED LAYER 0 SCRIPT Timer SLOT timer eve_declare_module(NAME system LAYER 0 SCRIPT System SLOT system THIRDPARTY sdl2 GROUP minimal 2d 3d web) +# Profiler: built-in engine-wide profiler. Scoped zones (common/Profile.h) are +# sprinkled across the core hot systems (physics, animation, particles, audio, +# ui, scene, map, crowd, event, graphics passes) and aggregated per frame into a +# per-module/per-zone call tree with self/total time; GPU frame time comes from +# Vulkan timestamp queries. Zero overhead when disabled. +eve_declare_module(NAME profiler LAYER 0 SCRIPT Profiler SLOT profiler + GROUP minimal 2d 3d web) eve_declare_module(NAME thread LAYER 0 SCRIPT Thread SLOT thread GROUP minimal 2d 3d web) eve_declare_module(NAME spatial LAYER 0 SCRIPT Spatial SLOT spatial @@ -336,7 +343,7 @@ eve_declare_module(NAME fluids LAYER 5 SCRIPT Fluids SLOT fluids DEPS gpgpu graphics physics GROUP 3d web) eve_declare_module(NAME procgen LAYER 5 SCRIPT Procgen SLOT procgen - DEPS graphics image map transaction + DEPS gpgpu graphics image map transaction GROUP 3d) # L5 -- RTS domain composition profile. Provider modules remain behind typed # links; these are the direct implementation dependencies of the profile. @@ -351,6 +358,12 @@ eve_declare_module(NAME tensor LAYER 5 LIB EVTensor SCRIPT TF SLOT tf eve_declare_module(NAME virtualgeometry LIB EVVirtualGeometry LAYER 5 SCRIPT VirtualGeometry DEPS data gpgpu graphics GROUP 3d) +# HD-2D: extrudes a 2D map::TileLayer into a 3D terrain mesh (TileMap3D) and +# renders 2D sprite sheets / characters as camera-facing 3D billboards +# (Sprite3D) via the ECS Renderable3D forward path. +eve_declare_module(NAME hd2d LIB EVHd2D LAYER 5 SCRIPT Hd2D SLOT hd2d + DEPS graphics map + GROUP 3d) # L6 -- orchestration eve_declare_module(NAME snow LAYER 6 SCRIPT Snow SLOT snow DEPS graphics procgen diff --git a/cmake/patches/sdl2-android-alooper-pollonce.patch b/cmake/patches/sdl2-android-alooper-pollonce.patch new file mode 100644 index 00000000..900f00ff --- /dev/null +++ b/cmake/patches/sdl2-android-alooper-pollonce.patch @@ -0,0 +1,6 @@ +diff --git a/SDL2/src/sensor/android/SDL_androidsensor.c b/SDL2/src/sensor/android/SDL_androidsensor.c +--- a/SDL2/src/sensor/android/SDL_androidsensor.c ++++ b/SDL2/src/sensor/android/SDL_androidsensor.c +@@ -174 +174 @@ +- if (ALooper_pollAll(0, NULL, &events, (void**)&source) == LOOPER_ID_USER) { ++ if (ALooper_pollOnce(0, NULL, &events, (void**)&source) == LOOPER_ID_USER) { diff --git a/cmake/patches/squirrel-binding-contract-expression-scope.patch b/cmake/patches/squirrel-binding-contract-expression-scope.patch new file mode 100644 index 00000000..917f538e --- /dev/null +++ b/cmake/patches/squirrel-binding-contract-expression-scope.patch @@ -0,0 +1,159 @@ +diff --git a/squirrel/squirrel/sqcompiler.cpp b/squirrel/squirrel/sqcompiler.cpp +index 04680d5..4ca1be2 100644 +--- a/squirrel/squirrel/sqcompiler.cpp ++++ b/squirrel/squirrel/sqcompiler.cpp +@@ -110,6 +110,7 @@ public: + _expectedunit = UNIT_NONE; + _expectedtype.clear(); + _expectednullable = true; ++ _expectedconstraintpending = false; + _scope.outers = 0; + _scope.stacksize = 0; + _compilererror[0] = _SC('\0'); +@@ -502,15 +503,18 @@ public: + std::vector > previousChoices = _expectedchoices; + std::basic_string previousType = _expectedtype; + bool previousNullable = _expectednullable; ++ bool previousConstraintPending = _expectedconstraintpending; + if(assignmentUnit != UNIT_NONE) _expectedunit = assignmentUnit; + if(!assignmentChoices.empty()) _expectedchoices = assignmentChoices; + _expectedtype = assignmentType; + _expectednullable = assignmentNullable; ++ _expectedconstraintpending = true; + Expression(); + _expectedunit = previousUnit; + _expectedchoices = previousChoices; + _expectedtype = previousType; + _expectednullable = previousNullable; ++ _expectedconstraintpending = previousConstraintPending; +- ++ + switch(op){ + case TK_NEWSLOT: +@@ -915,7 +919,7 @@ public: + } + } + } +- void NumericLiteral(bool negative) ++ void NumericLiteral(bool negative, bool applyExpectedConstraint) + { + const bool wasInteger = _token == TK_INTEGER; + const SQInteger integerValue = negative ? -_lex._nvalue : _lex._nvalue; +@@ -935,7 +939,7 @@ public: + else EmitLoadConstFloat(value, -1); + return; + } +- if(sourceUnit != UNIT_NONE && _expectedunit != UNIT_NONE) { ++ if(applyExpectedConstraint && sourceUnit != UNIT_NONE && _expectedunit != UNIT_NONE) { + if(sourceUnit == UNIT_MILLISECONDS && _expectedunit == UNIT_SECONDS) + value *= static_cast(0.001); + else if(sourceUnit == UNIT_DEGREES && _expectedunit == UNIT_RADIANS) +@@ -950,7 +954,9 @@ public: + } + SQInteger Factor() + { +- if(!_expectedtype.empty()) { ++ const bool applyExpectedConstraint = _expectedconstraintpending; ++ _expectedconstraintpending = false; ++ if(applyExpectedConstraint && !_expectedtype.empty()) { + const bool wantsString = _expectedtype == _SC("string"); + const bool wantsBool = _expectedtype == _SC("bool"); + const bool wantsInt = _expectedtype == _SC("int"); +@@ -967,7 +973,7 @@ public: + switch(_token) + { + case TK_STRING_LITERAL: +- if(!_expectedchoices.empty()) { ++ if(applyExpectedConstraint && !_expectedchoices.empty()) { + bool allowed = false; + for(SQUnsignedInteger i = 0; i < _expectedchoices.size(); ++i) { + if(scstrcmp(_expectedchoices[i].c_str(), _lex._svalue) == 0) { +@@ -1097,7 +1103,7 @@ public: + Lex(); + break; + case TK_INTEGER: +- case TK_FLOAT: NumericLiteral(false); break; ++ case TK_FLOAT: NumericLiteral(false, applyExpectedConstraint); break; + case TK_TRUE: case TK_FALSE: + _fs->AddInstruction(_OP_LOADBOOL, _fs->PushTarget(),_token == TK_TRUE?1:0); + Lex(); +@@ -1129,7 +1135,7 @@ public: + Lex(); + switch(_token) { + case TK_INTEGER: +- case TK_FLOAT: NumericLiteral(true); break; ++ case TK_FLOAT: NumericLiteral(true, applyExpectedConstraint); break; + default: UnaryOP(_OP_NEG); + } + break; +@@ -1232,19 +1238,21 @@ public: + std::vector > previousChoices = _expectedchoices; + std::basic_string previousType = _expectedtype; + bool previousNullable = _expectednullable; ++ bool previousConstraintPending = _expectedconstraintpending; + if(callSignature != NULL && parameterIndex >= 0 && +- static_cast(parameterIndex) < callSignature->parameters.size()) { ++ static_cast(parameterIndex) < callSignature->parameters.size()) { + _expectedunit = callSignature->parameters[parameterIndex].unit; + _expectedtype = callSignature->parameters[parameterIndex].type; + _expectednullable = callSignature->parameters[parameterIndex].nullable; +- if(!callSignature->parameters[parameterIndex].choices.empty()) +- _expectedchoices = callSignature->parameters[parameterIndex].choices; ++ _expectedchoices = callSignature->parameters[parameterIndex].choices; ++ _expectedconstraintpending = true; + } + Expression(); + _expectedunit = previousUnit; + _expectedchoices = previousChoices; + _expectedtype = previousType; + _expectednullable = previousNullable; ++ _expectedconstraintpending = previousConstraintPending; + MoveIfCurrentTargetIsLocal(); + argumentNames.push_back(argumentName); + argumentTargets.push_back(_fs->TopTarget()); +@@ -1530,15 +1538,18 @@ public: + std::vector > previousChoices = _expectedchoices; + std::basic_string previousType = _expectedtype; + bool previousNullable = _expectednullable; ++ bool previousConstraintPending = _expectedconstraintpending; + if(declaredUnit != UNIT_NONE) _expectedunit = declaredUnit; + if(!declaredChoices.empty()) _expectedchoices = declaredChoices; + _expectedtype = declaredType; + _expectednullable = declaredNullable; ++ _expectedconstraintpending = true; + Expression(); + _expectedunit = previousUnit; + _expectedchoices = previousChoices; + _expectedtype = previousType; + _expectednullable = previousNullable; ++ _expectedconstraintpending = previousConstraintPending; + } + } + if(_token == separator) Lex();//optional comma/semicolon +@@ -1593,15 +1604,18 @@ public: + std::vector > previousChoices = _expectedchoices; + std::basic_string previousType = _expectedtype; + bool previousNullable = _expectednullable; ++ bool previousConstraintPending = _expectedconstraintpending; + if(declaredUnit != UNIT_NONE) _expectedunit = declaredUnit; + if(!declaredChoices.empty()) _expectedchoices = declaredChoices; + _expectedtype = declaredType; + _expectednullable = declaredNullable; ++ _expectedconstraintpending = true; + Expression(); + _expectedunit = previousUnit; + _expectedchoices = previousChoices; + _expectedtype = previousType; + _expectednullable = previousNullable; ++ _expectedconstraintpending = previousConstraintPending; + SQInteger src = _fs->PopTarget(); + SQInteger dest = _fs->PushTarget(); + if(dest != src) _fs->AddInstruction(_OP_MOVE, dest, src); +@@ -2463,6 +2477,7 @@ private: + SQScriptUnit _expectedunit; + std::basic_string _expectedtype; + bool _expectednullable; ++ bool _expectedconstraintpending; + std::vector > _expectedchoices; + std::map, std::vector > > _choiceSymbols; + SQInteger _debugline; diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt index 241993f0..c8b89519 100644 --- a/docs/CMakeLists.txt +++ b/docs/CMakeLists.txt @@ -53,15 +53,22 @@ file(MAKE_DIRECTORY "${_doc_pages_dir}/docs/usr/modules" "${_doc_pages_dir}/docs/dev") -# User guide root: EVEngine 用户指南 -> module handbook / trimming / conventions. +# User guide root: EVEngine 用户指南 -> language tutorial / module handbook / +# trimming / conventions. file(READ "${DOC_TOP_DIR}/usr/README.md" _doc_usr_readme ENCODING UTF-8) file(WRITE "${_doc_pages_dir}/docs/usr/README.md" "\\page usr_guide EVEngine 用户指南\n\n" + "\\subpage usr_evescript\n\n" "\\subpage usr_modules\n\n" "\\subpage usr_trimming\n\n" "\\subpage usr_conventions\n\n" "${_doc_usr_readme}") +# EveScript language and migration tutorial. +file(READ "${DOC_TOP_DIR}/usr/EVESCRIPT.md" _doc_evescript ENCODING UTF-8) +file(WRITE "${_doc_pages_dir}/docs/usr/EVESCRIPT.md" + "\\page usr_evescript EveScript 完整教程\n\n${_doc_evescript}") + # Module handbook: groups the five category pages (defined in docs/dox/). file(READ "${DOC_TOP_DIR}/usr/MODULES.md" _doc_modules ENCODING UTF-8) file(WRITE "${_doc_pages_dir}/docs/usr/MODULES.md" diff --git a/docs/dev/baselines/pcg-million-point.json b/docs/dev/baselines/pcg-million-point.json new file mode 100644 index 00000000..1f100c44 --- /dev/null +++ b/docs/dev/baselines/pcg-million-point.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "workload": "pcg-four-transform-million-point", + "pointCount": 1000000, + "logicalOperationCount": 4, + "warmupCount": 5, + "sampleCount": 20, + "requiredTransfers": { + "uploadsPerExecution": 1, + "dispatchesPerExecution": 1, + "readbacksPerExecution": 1 + }, + "requiredSpeedups": { + "overCpu": 2.0, + "overFourIsolatedGpuRoundTrips": 1.5 + }, + "measurement": "median", + "run": "EVENGINE_PCG_MILLION_BENCHMARK=1 make test/linux-debug FILTER=gpgpu.procgen.millionPointAcceptanceBenchmark" +} diff --git a/docs/dev/superpowers/specs/2026-08-26-pcg-aaa-gpu-execution.md b/docs/dev/superpowers/specs/2026-08-26-pcg-aaa-gpu-execution.md new file mode 100644 index 00000000..dc1cdd71 --- /dev/null +++ b/docs/dev/superpowers/specs/2026-08-26-pcg-aaa-gpu-execution.md @@ -0,0 +1,116 @@ +# PCG AAA GPU execution contract + +## Goal + +Move `PointGraph` from isolated GPU transforms to production graph execution comparable with +modern UE/Unity PCG workflows: large point sets stay resident on the active Vulkan/WebGPU +backend, compatible unary nodes fuse into one segment, editor metrics explain every barrier and +fallback, and CPU execution remains a deterministic reference path. + +This document is an acceptance contract. A backend is not considered complete because one +kernel dispatches successfully. + +## Execution model + +The compiler lowers an evaluated graph into ordered segments. A GPU segment begins at a CPU to +GPU upload and may contain consecutive supported unary operations. It ends at a topology-changing +operation, a branch whose other consumer needs a host value, an editor readback, an unsupported +attribute type, cancellation, or graph output. One upload and one final readback are allowed per +segment; intermediate nodes must not round-trip through host memory. + +Initial fusible operations are: + +- `transform` +- `density.remap` +- `attribute.math.float` for built-in numeric point fields +- `attribute.set.float` for built-in numeric point fields + +`merge`, `copy.points`, spatial queries, biome/grammar generation, string attributes and nested +graph boundaries are explicit barriers until they have dedicated kernels. Branches preserve each +logical node cache: the compiler may retain a GPU snapshot or materialize once, but must never +silently recompute a random or external-data node. + +## Required semantics + +1. `cpu`, `gpu`, and `auto` policies produce the same point count, seeds, string metadata and + topology. Numeric GPU fields match the CPU reference within `1e-4` absolute error. +2. Stable seeds make independent graph instances bit-stable on the CPU path. GPU results are + stable on repeated execution of the same backend and device. +3. Incremental invalidation recompiles only segments downstream of the changed node. Cached + upstream segments and unrelated branches remain valid. +4. Cancellation is checked before upload and between segments. A cancelled or failed segment + publishes no partial node cache. +5. Shader compilation, allocation, submission, device loss and readback errors retry the whole + segment on CPU. Metrics retain the backend error and identify the barrier/fallback node. +6. Node output budgets are checked before GPU allocation. Byte-size arithmetic uses checked + 64-bit calculations and rejects values outside backend buffer limits. + +## Observability + +Every execution report must expose: + +- compiled segment count and fused logical-node count; +- GPU dispatch, upload and readback counts and bytes; +- backend (`cpu`, `vulkan`, `webgpu`) per logical node; +- compilation and execution milliseconds per segment; +- barrier reason and CPU fallback reason; +- peak resident GPU bytes and buffer reuse count. + +Editor preview requests may force a readback and must label it `editor-preview`, so profiling does +not mistake debug observation for normal runtime cost. + +## Performance acceptance + +CI records results rather than enforcing wall-clock thresholds on shared runners. A dedicated +reference machine enforces these budgets after a baseline is checked in: + +| Workload | Required result | +| --- | --- | +| 1,000,000 points, four compatible unary nodes | one upload, one dispatch segment, one readback | +| Repeat unchanged graph | no dispatch; all requested outputs served from cache | +| Change final node parameter | upstream buffers reused; only final affected segment executes | +| Forced GPU allocation/submit failure | complete CPU result, no partial cache, reason reported | +| 64-cell streaming window | resident point and byte budgets never exceeded | + +For the million-point chain, the fused GPU path must be at least 2x faster than the CPU reference +and at least 1.5x faster than four isolated GPU round-trips on the reference discrete GPU. Median +of 20 measured runs is used after 5 warmups; shader compilation is reported separately. + +## Cross-platform gates + +- Vulkan Lavapipe: correctness, validation-layer cleanliness and forced-failure recovery. +- Windows Vulkan plus native Dawn: CPU/Vulkan/WebGPU parity and resource-reuse execution. +- macOS native Dawn/Metal: compilation, dispatch and repeated-run stability. +- WebGPU/WASM: shader compilation and artifact build; browser execution parity where an adapter + is available. +- ASan/UBSan: graph compilation, invalidation, cancellation and fallback ownership paths. + +## Completion evidence + +Completion requires all of the following in the same PR SHA: + +- graph compiler tests proving barrier placement and incremental recompilation; +- backend parity tests covering every fusible operation and a four-node fused chain; +- failure-injection tests for allocation, compilation, dispatch and readback; +- a checked-in benchmark fixture and machine-readable baseline; +- green full platform CI, including ASan/UBSan and native Dawn parity; +- documentation and script bindings for all public metrics and policy controls. + +## Acceptance commands + +The regular `gpgpu.procgen` cases enforce graph-segment transfer counts, lazy intermediate +preview, cache reuse and atomic CPU recovery from forced `compile`, `allocation`, `submit` and +`readback` failures. The failure selector is the process-local +`EVENGINE_POINT_COMPUTE_FAIL` environment variable and is intended only for validation. + +Run the dedicated reference-machine benchmark with: + +```sh +EVENGINE_PCG_MILLION_BENCHMARK=1 make test/linux-debug \ + FILTER=gpgpu.procgen.millionPointAcceptanceBenchmark +``` + +It performs five GPU warmups and twenty measured CPU, fused-GPU and four-round-trip GPU samples, +then prints one `PCG_BENCHMARK_JSON=...` record. Required structure and speedups are versioned in +`docs/dev/baselines/pcg-million-point.json`; shared CI records correctness and transfer counts but +does not enforce wall-clock ratios. diff --git a/docs/dev/superpowers/specs/2026-08-26-webgpu-vkbuilder-style-cpp-layer.md b/docs/dev/superpowers/specs/2026-08-26-webgpu-vkbuilder-style-cpp-layer.md new file mode 100644 index 00000000..ee32b7df --- /dev/null +++ b/docs/dev/superpowers/specs/2026-08-26-webgpu-vkbuilder-style-cpp-layer.md @@ -0,0 +1,263 @@ +# WebGPU 后端对标 vk-bootstrap:C++ 系统化封装层,编译期阻挡不可行的 API 调用 + +日期:2026-08-26 +状态:已确认,待实现计划 +分支:`codex/webgpu-vkbuilder` + +## 背景与问题 + +Vulkan 后端依赖 `external/VKBuilder`(`vkbuilder.hpp`):用 RAII 句柄(`GenericBuffer` / +`TextureImage2D` / `ColorAttachmentImage` / `DepthTarget`)、状态机(`UnboundSet` → +`BoundSet`)、Builder 链(`DescriptorSetBuilder` / `SamplerBuilder` / `PipelineBuilder`)把 +**不可行 / 非法的 API 组合在编译期或类型层面阻挡**:`Device` 未建好前不能调设备级函数、 +buffer 未 upload 不能 bind、错误参数无法拼出合法调用等。 + +而 WebGPU 后端(`src/modules/graphics/webgpu/Graphics.cpp`,4527 行)目前**没有**这套系统化 +防护,直接在引擎层混用三层 API: + +1. **原始 C API**(`WGPU*` 结构体 + `wgpuBufferMapAsync` / `wgpuSurfaceGetCapabilities` / + `wgpuInstanceProcessEvents` 等自由函数)——624 处 `WGPU*` 用法; +2. **Dawn/emdawn 的 C++ RAII 包装**(`wgpu::Device` / `wgpu::Buffer` / `wgpu::RenderPipeline`) + ——143 处 `.Get()`; +3. **两者互转**:107 处 `reinterpret_cast`(把 `WGPU*Descriptor` 当作 `wgpu::*Descriptor` + 传递,或反向),全靠 ABI 布局巧合成立。 + +由此产生四类"不可行 API 调用"在今天只是靠注释/经验规避,随时会炸: + +| 问题 | 现状(Graphics.cpp) | 后果 | +|------|----------------------|------| +| **C↔C++ 结构体 ABI 混转** | 107 处 `reinterpret_cast`,`WGPUBufferDescriptor` ↔ `wgpu::BufferDescriptor` | 依赖 ABI 布局相同;结构体一改就静默错位,无编译期检查 | +| **默认值陷阱** | `WGPU*Descriptor d{}` 零初始化后手动设字段;`writeMask`/`sampleMask`/`alphaMode` 显式 `0` 或 `0xFFFFFFFF` | 零初始化 ≠ WebGPU 默认值(如 `writeMask=0` 全丢弃、`alphaMode=0` 为空槽),改动即出黑屏/全白 | +| **异步初始化无状态机** | `adapterReceived`/`deviceReceived`/`swapchainConfigured`/`initialized` 等 `std::atomic` + 忙等 `waitForAdapter/waitForDevice` | 状态靠运行时布尔拼凑,顺序错(先取 surface 再 request adapter)编译不过但运行时炸 | +| **不可行调用仅 stub** | `setMesh3DSSAO`/`beginDecalPass`/`supportsGBufferPost()==false` 空实现 | 引擎上层不知道哪些调用真正生效;静默吞掉 ≠ 明确拒绝 | + +**目标**:为 WebGPU 后端建立一套**对标 vkbuilder 的 C++ 系统化封装层**,把"不可行的 API 调用 +逻辑"在编译期(类型/签名/状态)阻挡住,而不是运行时靠断言或运气。这是纯增量重构——不改变 +`eve::graphics::Graphics` 公共接口,不改脚本绑定,不改渲染结果。 + +## 对标矩阵(VKBuilder 支柱 → WebGPU 对应) + +| VKBuilder 支柱 | WebGPU 对应方案 | 阻挡什么 | +|----------------|------------------|----------| +| RAII 资源句柄 | `wgpu::*` 已是 RAII;用 `webgpu::Resource` 家族统一封装 + 强类型持有 | 裸句柄泄漏 / 悬垂 | +| `UnboundSet`→`BoundSet` 状态机 | `webgpu::BindGroup` 的"可写→可读→已提交"类型态 | 绑定未完成就 draw | +| Builder 链默认合法 | `webgpu::PipelineBuilder` / `BindGroupLayoutBuilder` / `SamplerBuilder` / `TextureBuilder`,**默认值即合法值** | `writeMask=0` 黑屏 / `sampleMask=0` 丢弃 / `alphaMode` 空槽 | +| 初始化分步,非法顺序不可表示 | `webgpu::InitFlow`(`Instance→Adapter→Device→Surface→Configure`) | 在 device 前调 surface/提交队列 | +| 能力探测后再开功能 | `webgpu::Capabilities`(adapter/device limits 查询) | 请求超限或不可用的 sample count / format | +| 明确失败而非静默 | 每个 `Graphics` 空实现改抛 `Exception` + `supportsX()` 已返回 false | 上层误用不可行功能时获得确定性错误 | + +## 架构 + +``` +src/modules/graphics/webgpu/ + wgpu_types.h # 轻量工具:字符串视图、类型标签、枚举封装的统一 using + Resource.h # 资源 RAII 家族(沿用 wgpu::*,但收敛 .Get() 泄漏点) + Capabilities.h/.cpp # adapter/device 能力与 limits 的一次性查询缓存 + InitFlow.h/.cpp # 分步初始化状态机(Instance→Adapter→Device→Surface→Configure) + Builder.h/.cpp # Pipeline/BindGroupLayout/Sampler/Texture Builder 链 + Graphics.cpp # 改造为只消费上述层;消除全部 reinterpret_cast 与手写零初始化 +``` + +### 分层与依赖 + +- 公共 `Graphics` 接口、`gpgpu/webgpu`、`imgui_impl_wgpu` 都不感知本层(仍用 `wgpu::*`)。 +- `Builder.h`/`Capabilities.h`/`InitFlow.h` 是 `graphics/webgpu` 模块内部头,按 + AGENTS.md 跨模块规则不向上 `#include`。 +- 新层只向下依赖 `wgpu::*`(webgpu_cpp.h),不引入新的 `reinterpret_cast` 之外的 ABI 假设。 + +## 关键设计 + +### 1. `InitFlow`:把"不可行的初始化顺序"变成编译期不可表示 + +`Graphics::initWithWindow` 今天把 adapter 回调、device 回调、surface 创建、configure 全部塞在一个 +函数里,用 4 个 `std::atomic` 串起来。改为**类型驱动的一次性流程**: + +```cpp +class InitFlow { +public: + // 阶段对象只在上一阶段成功后才可取得;顺序由类型链保证。 + struct InstanceDone { wgpu::Instance instance; }; + struct AdapterDone { wgpu::Instance instance; wgpu::Adapter adapter; }; + struct DeviceDone { + wgpu::Instance instance; + wgpu::Adapter adapter; + wgpu::Device device; + wgpu::Queue queue; + Capabilities caps; // 建好 device 后立即缓存能力 + }; + struct SurfaceDone { + wgpu::Instance instance; + wgpu::Adapter adapter; + wgpu::Device device; + wgpu::Queue queue; + wgpu::Surface surface; + WGPUTextureFormat surfaceFormat; // 来自 wgpuSurfaceGetCapabilities + Capabilities caps; + }; + struct Ready { // Configure 完成 + wgpu::Device device; + wgpu::Queue queue; + wgpu::Surface surface; + WGPUTextureFormat surfaceFormat; + Capabilities caps; + }; + + InstanceDone createInstance(); // 空实例;无 adapter 前不暴露设备 API + AdapterDone requestAdapter(InstanceDone); // 仅从 InstanceDone 取得 + DeviceDone requestDevice(AdapterDone); // 仅从 AdapterDone 取得 + SurfaceDone createSurface(DeviceDone, void* nativeWindow); + Ready configure(SurfaceDone, int w, int h); // 仅从 SurfaceDone 取得 +}; +``` + +- **阻挡**:想跳步(拿 `SurfaceDone` 前就调 `configure`)、拿 `DeviceDone` 前就 `createSurface` + 都是**编译期**类型不匹配,直接编译失败。 +- 回调转同步:保留现有 `wgpuInstanceRequestAdapter` + `ProcessEvents` 忙等语义(浏览器/Emscripten + 行为不变),但把布尔聚合成一个阶段结果,消除 `waitForAdapter/waitForDevice` 的散落状态。 +- `Graphics` 持有 `Ready` 之后只暴露 `device()/queue()/surface()` 的 `const` 访问,杜绝中途改头。 + +### 2. `Capabilities`:能力探测后才允许请求资源 + +把 `wgpuAdapterGetLimits` / `wgpuDeviceGetLimits` / `wgpuSurfaceGetCapabilities` 的查询结果 +**缓存成只读快照**,并暴露谓词: + +```cpp +class Capabilities { +public: + static Capabilities query(wgpu::Instance, wgpu::Adapter, + const wgpu::Surface &, WGPUTextureFormat defaultFormat); + bool supportsSampleCount(uint32_t) const; + bool supportsFormat(WGPUTextureFormat) const; + uint32_t maxTextureDimension() const; + uint32_t maxBindGroups() const; + uint32_t maxDynamicUniformBuffers() const; + // …更多 limits 谓词按需添加 +}; +``` + +- `createSceneColorResources`/`configureSurface` 用 `supportsSampleCount` 决定 MSAA, + 而不是硬编码 1/4。 +- 自定义 shader / 多 bind group 路径用 `maxBindGroups` 防护。 +- 只读、一次构造,杜绝中途查询不一致。 + +### 3. `Builder`:默认值即合法值,消除零初始化陷阱 + +把 Graphics.cpp 里手写 `WGPU*Descriptor` + `reinterpret_cast` 的地方收敛成 Builder 链。 +**Builder 构造即写入合法默认**(对应 WebGPU 语义而非零值),用户只需覆盖要改的字段: + +```cpp +class SamplerBuilder { +public: + SamplerBuilder(); // 默认:ClampToEdge + Linear + maxAnisotropy=1 + SamplerBuilder &filter(WGPUFilterMode mag, WGPUFilterMode min); + SamplerBuilder &address(WGPUAddressMode u, WGPUAddressMode v, WGPUAddressMode w); + SamplerBuilder &compare(WGPUCompareFunction cmp); // 设了 compare 则必须是 Comparison 绑定 + SamplerBuilder &anisotropy(uint32_t a); + wgpu::Sampler build(const wgpu::Device &) const; +}; + +class PipelineBuilder { // 默认 writeMask=All、sampleMask=0xFFFFFFFF、 + // alphaMode 不设空槽、blend 显式选择 +public: + PipelineBuilder(wgpu::Device, WGPUTextureFormat format); + PipelineBuilder &blend(BlendMode mode); // 枚举驱动,杜绝手写 blend 状态错配 + PipelineBuilder &depth(WGPUCompareFunction, bool write); + PipelineBuilder &sampleCount(uint32_t count); + PipelineBuilder &cull(WGPUCullMode); + wgpu::RenderPipeline build() const; +}; +``` + +- `BindGroupLayoutBuilder` 承担 12/15 条 entry 的构造,按 `GpuShader` 的 `isMesh3D` / + `isGbuffer` / `isShadow` 自动选绑定集合。 +- **阻挡**:`writeMask`/`sampleMask`/`alphaMode` 的"默认丢帧"问题从源头消失——Builder 构造时 + 就是正确值;`compare` 与绑定类型不一致时 `build()` 抛 `Exception`。 + +### 4. 空实现 stub 改为"明确拒绝" + +对标 VKBuilder"不可用即不可调",把 WebGPU 里 `setMesh3DSSAO` / `beginDecalPass` / +`drawDecal` / `endDecalPass` 这类空实现改为: + +- 保持 `supportsGBufferPost()==false` / `supportsDecal()==false` 不变(上层本就不该调); +- 若仍被调(误用),抛 `Exception("WebGPU: N 不受支持")` 而不是静默 `(void)`。 +- 这属于**行为收紧**,需同步检索引擎上层是否真的会调这些(`RenderSystem3D` 等按 + `supportsX()` 分流)。若存在无条件调用,先修上层分流再收紧,避免回归。 + +## 实施顺序(PR 粒度,每个 PR 独立可编译可测) + +1. **PR1:能力与工具层**(`wgpu_types.h`、`Capabilities`、`SamplerBuilder`)——纯新增, + 无行为变化;`Graphics` 先不消费。 +2. **PR2:`InitFlow` 初始化状态机**——替换 `initWithWindow` 内部状态;对外 `Graphics` + 公共方法签名不变;浏览器/native 双端回归。 +3. **PR3:`PipelineBuilder`/`BindGroupLayoutBuilder`**——替换 `make2D*/makeMesh3D*` 等 + 手写 descriptor;覆盖默认值陷阱。 +4. **PR4:空实现 stub 收紧**——`SSAO`/`decal` 明确抛错,先修上层分流。 +5. **PR5:清理**——移除 Graphics.cpp 剩余 `reinterpret_cast` 与零初始化遗留;确认 + `rg -n reinterpret_cast` 在 `graphics/webgpu` 归零(imgui_impl_wgpu 单列是否纳入)。 + +## 实施记录(2026-08-26) + +- **PR1 完成**:新增 `wgpu_types.h`、`Capabilities.h/.cpp`、`SamplerBuilder.h/.cpp`。 + `SamplerBuilder::build` 直接构造 `wgpu::SamplerDescriptor`(零初始化即 WebGPU 默认, + 无 reinterpret_cast);`fromEngine` 精确复刻 `Graphics::makeSampler` 语义。 +- **PR2 完成**:新增 `InitFlow.h/.cpp`,把 `Instance→Adapter→Device` 的异步 request + + ProcessEvents 忙等封装成类型链(`InstanceDone→AdapterDone→DeviceDone`),跳步即编译失败。 + 删除 `createInstanceAndAdapter/requestDevice/waitForAdapter/waitForDevice` 及 + `adapterReceived/deviceReceived/adapterError/deviceError`,改为持有 `Capabilities caps`。 +- **PR3 完成**:新增 `PipelineBuilder`/`BindGroupLayoutBuilder`,直接构造 C++ wrapper + descriptor(默认即合法)。已迁移 8 个 bind-group-layout 函数、7 个 pipeline-layout 函数、 + 3 个 2D pipeline 函数、`makeWgslModule`。 +- **PR4 取消(已过时)**:dev 分支的 SSAO / decal 已是真实实现(`supportsGBufferPost()` / + `supportsDecal()` 均返回 true,`setMesh3DSSAO`/`beginDecalPass`/`drawDecal` 有实质逻辑), + 无空实现 stub 可收紧。 +- **PR5 部分完成**:`reinterpret_cast` 从 127 处降到 114 处(bind-group-layout / + pipeline-layout / 2D pipeline / shader module 已归零)。剩余 114 处为 texture/buffer/ + bindgroup/renderpass 的 C→C++ descriptor 转换,ABI 稳定但改动面大,留待后续 PR 分批 + 迁移并需在 webgpu-native 构建上回归。 + +### 后续待办(PR5 剩余) + +- 迁移 `TextureDescriptor` / `BufferDescriptor` / `BindGroupDescriptor` / + `RenderPassDescriptor` / `TexelCopy*` 的 `reinterpret_cast` 到 C++ wrapper descriptor。 +- 迁移后必须在 `build/webgpu-native`(Dawn)与 `build/webgpu-web`(Emscripten)上回归 + parity 测试(`2026-08-24-webgpu-vulkan-render-parity.md`)。 + +### 编译验证(2026-08-26,Windows native Dawn) + +`build/webgpu-native`(`BUILD_PLATFORM=webgpu`,MSVC Debug + Ninja)配置并构建成功: + +- 配置需初始化 `external/*` submodules;本机 `EVENGINE_COMPILER_CACHE`(sccache)缓存损坏, + 以 `-DEVENGINE_COMPILER_CACHE=OFF` 关闭后正常。 +- 编译期修 3 处错误: + 1. `sv()` 与 Graphics.cpp/Canvas.cpp 的匿名命名空间 `sv(const char*)` 重载歧义(C2668) + → 删除本地重复的 `sv`,统一用 `wgpu_types.h`。 + 2. `Capabilities.cpp` 丢失 `kDefaultSampleCount` → 恢复匿名命名空间常量。 + 3. `PipelineBuilder::colorTarget/depth` 参数类型 C 枚举 `WGPUTextureFormat` 与 C++ 枚举 + `wgpu::TextureFormat` 无隐式转换(C2664)→ 改为接收 `WGPUTextureFormat` 内部 + `static_cast`。 +- `EVGraphics` 模块与 `eve.exe`(70.1 MB)均成功编译链接(1633/1633)。 + +## 测试与回归 + +- 每个 PR 跑 `make test/linux-debug FILTER=webgpu*`(native Dawn)与浏览器构建 + `build/webgpu-web` 冒烟(`scripts/smoke_examples.sh`)。 +- 关键验证点: + - `InitFlow` 非法顺序为编译失败(用 `static_assert`/`delete` 明确"不可调"的 API)。 + - 默认值:不设 blend 的 `PipelineBuilder` 产生 Alpha 而非零掩码(对比提交前的黑屏复现)。 + - 能力:MSAA=4 场景在 `supportsSampleCount(4)==false` 的软渲设备上不崩、降级到 1。 + - 空实现 stub 收紧后,`RenderSystem3D` 在 decal/SSAO 关闭时不再触发。 + +## 明确不做(YAGNI) + +- 不改 `eve::graphics::Graphics` 公共接口 / 脚本绑定 / 渲染结果。 +- 不做 WebGPU→Vulkan 的统一抽象(现有 Vulkan 走 vkbuilder、WebGPU 走本层,各自保持)。 +- 不把 `imgui_impl_wgpu.cpp` 纳入重构(第三方风格,保持独立)。 +- 不引入跨模块新头依赖(严格按 AGENTS.md 分层)。 + +## 成功标准 + +1. `src/modules/graphics/webgpu` 中 `reinterpret_cast` 归零;`WGPU*Descriptor` 手写零初始化 + 归零。 +2. 不可行的初始化顺序、越能力请求、默认掩码陷阱在编译期或类型层面被阻挡。 +3. 空实现 stub 不再静默吞调用;误用即确定性 `Exception`。 +4. WebGPU 与 Vulkan 在既有 parity 测试上仍通过(`2026-08-24-webgpu-vulkan-render-parity.md`)。 \ No newline at end of file diff --git a/docs/dox/mainpage.dox b/docs/dox/mainpage.dox index f438812d..46b5e4eb 100644 --- a/docs/dox/mainpage.dox +++ b/docs/dox/mainpage.dox @@ -17,6 +17,7 @@ * * @par 文档导航 * - @ref usr_guide "EVEngine 用户指南":下载、创建并运行游戏、调试与打包发布; + * - @ref usr_evescript "EveScript 完整教程":语言语法、编码规范、异步、模块与 Squirrel 迁移; * - @ref usr_modules "模块使用手册":按功能逐个说明脚本 API 与示例(也可从用户指南进入); * - @ref usr_trimming "按需裁剪模块":从源码构建时按档位裁剪模块; * - @ref usr_conventions "API 使用约定":模块对象、生命周期与脚本 API 边界; diff --git a/docs/usr/EVESCRIPT.md b/docs/usr/EVESCRIPT.md new file mode 100644 index 00000000..0786d75a --- /dev/null +++ b/docs/usr/EVESCRIPT.md @@ -0,0 +1,888 @@ +# EveScript 完整教程 + +EveScript 是 EVEngine 的游戏脚本语言。它以 Squirrel 为基础,保留 Squirrel 的对象模型、闭包、 +协程和动态类型能力,同时增加脚本模块、渐进类型、空值运算、命名参数、Inspector 元数据、热重载 +持久变量、单位字面量、模式匹配以及 `async` / `await`。 + +所有游戏脚本仍使用 `.nut` 扩展名。项目不需要语言标记,也没有“启用 EveScript”的开关: +`config.nut`、`main.nut`、导入的脚本、REPL、调试器和 MCP 执行的代码都使用同一套语法。 +没有使用扩展语法的标准 Squirrel 文件可以直接运行。 + +本教程面向使用 SDK 制作游戏的开发者。模块 API(图形、物理、UI、ECS 等)请配合 +[模块使用手册](MODULES.md)查阅。 + +## 1. 第一个 EveScript 游戏 + +一个游戏至少包含 `config.nut` 和 `main.nut`: + +```text +my-game/ +├── config.nut +├── main.nut +└── scripts/ + └── movement.nut +``` + +`config.nut` 是普通 Squirrel 配置脚本。模块需求是整个项目的属性,因此在这里统一声明: + +```squirrel +config <- { + width = 960 + height = 540 + title = "EveScript Tutorial" + hotReload = true + + modules = ["gfx", "keyboard", "timer"] + optionalModules = ["audio"] +} +``` + +- `modules` 中的模块必须存在,否则引擎会在执行 `main.nut` 前停止并报告缺失项; +- `optionalModules` 用于工具提示和项目检查,使用前仍要调用 `has_module()`; +- 名称是脚本中的 root slot,例如图形模块写 `gfx`,不是 CMake 名称 `graphics`; +- 这两个字段必须是字符串数组字面量,语言服务器和打包器不会执行任意配置代码来推断模块。 + +`scripts/movement.nut`: + +```squirrel +export const PLAYER_SPEED = 180.0 + +export function wrap_x(x: float, width: float) -> float { + return x > width ? -48.0 : x +} +``` + +`main.nut`: + +```squirrel +import { PLAYER_SPEED, wrap_x } from "./scripts/movement.nut" + +persist playerX: float = 100.0 + +eve_init <- function() { + gfx.setBackgroundColor(0.08, 0.10, 0.16, 1.0) +} + +eve_update <- function(dt: float) { + playerX = wrap_x(playerX + PLAYER_SPEED * dt, config.width) +} + +eve_render <- function() { + gfx.clear() + gfx.drawSolidRect(playerX, 220.0, 48.0, 48.0, 0.3, 0.75, 1.0, 1.0) +} +``` + +运行: + +```sh +eve run my-game +``` + +保存脚本后,`persist playerX` 会在热重载时保留当前位置;函数和常量则由新代码替换。 + +从旧项目批量迁移 root 持久状态时,可先预览再写入: + +```sh +python scripts/migrate_evescript.py --check +python scripts/migrate_evescript.py --write +``` + +迁移器只改写可证明等价的 root-table 判断和 `persist(name, initializer)` 样板;普通 Squirrel、 +动态 root-table 操作和兼容性 `dofile()` 不会被猜测式重写。 + +## 2. 仍然需要掌握的 Squirrel 基础 + +EveScript 是 Squirrel 的兼容扩展,不是另一套对象模型。以下写法仍是日常代码的主体。 + +### 2.1 源文件、注释和语句 + +源码采用 UTF-8。标识符、字符串、数字和运算符沿用 Squirrel;语言关键字区分大小写。支持行注释 +与块注释: + +```squirrel +// 行注释 + +/* + * 块注释 + */ +``` + +分号与标准 Squirrel 一样可以作为语句终止符。项目代码建议依靠换行和大括号,不混用多种风格; +同一行放多条语句时必须用分号分隔。 + +常用运算符仍包括算术 `+ - * / %`、比较 `== != < <= > >=`、逻辑 `&& || !`、三元表达式 +`condition ? yes : no`、成员访问 `.` / `[]` 和赋值运算。EveScript 新增的 `?.`、`??`、`??=` +见第 5 节。 + +`import`、`export`、`async`、`await`、`persist` 和 `match` 在对应语法位置具有语言含义;`from` +和 `as` 只在 import 子句中按关键字解析,因此旧脚本仍可把它们用作普通标识符。类型名称和单位 +名称也主要在类型/字面量上下文中解析。 + +### 2.2 值、局部变量和 root slot + +```squirrel +local lives = 3 +local speed = 120.0 +local title = "Arena" +local enabled = true +local target = null + +local spawn = { x = 20.0, y = 40.0 } +local enemies = ["slime", "bat"] +``` + +`local` 创建词法作用域局部变量。`<-` 在表中创建新 slot,`=` 修改已有 slot: + +```squirrel +gameState <- { score = 0 } // 在 root table 创建 gameState +gameState.score = 10 // 修改已有字段 +``` + +优先使用 `local`、模块内私有值和显式 `export`。只有引擎生命周期回调等约定入口才需要放在 root +table。不要用 `const` 表达运行时只读状态:Squirrel 的 `const` 是编译期常量声明,适合固定数值和 +导出的常量;普通可变游戏状态使用变量。 + +### 2.3 函数、闭包和默认参数 + +```squirrel +function clamp_health(value, maximum = 100) { + return value < 0 ? 0 : (value > maximum ? maximum : value) +} + +local multiplier = 2 +local scale = function(value) { + return value * multiplier // 闭包捕获外层变量 +} +``` + +### 2.4 表、数组和遍历 + +```squirrel +local inventory = { + potion = 2 + key = 1 +} + +inventory.potion += 1 +inventory["coin"] <- 20 + +foreach (name, count in inventory) + print(name + ": " + count + "\n") + +local actors = ["hero", "merchant"] +actors.append("guard") +foreach (index, actor in actors) + print(index + ": " + actor + "\n") +``` + +### 2.5 类、继承和 `this` + +```squirrel +class Actor { + name = "" + health = 100 + + constructor(name) { + this.name = name + } + + function damage(amount) { + health -= amount + } +} + +class Player extends Actor { + coins = 0 +} + +local player = Player("Ada") +player.damage(10) +``` + +类字段的默认值属于 Squirrel 类语义。引擎原生对象也通过相同的成员调用形式使用,但可调用的接口 +以模块手册和绑定契约为准,并非 C++ 类的所有 public 方法。 + +### 2.6 控制流、异常和 generator + +```squirrel +if (player.health <= 0) { + print("game over\n") +} else { + player.damage(1) +} + +for (local i = 0; i < 3; ++i) { + // ... +} + +try { + load_save() +} catch (error) { + print("load failed: " + error + "\n") +} + +function dialogue_lines() { + yield "Hello" + yield "Welcome to EVEngine" +} +``` + +`yield` 和 Squirrel generator 仍然保留,适合由调用者主动推进的序列。需要等待 Promise、计时器或 +主线程事件时,优先使用 EveScript 的 `async function`。 + +## 3. 脚本模块:`import` 与 `export` + +新代码使用模块,而不是用 `dofile()` 把多个文件写入同一个全局表。 + +### 3.1 导出 + +模块 root scope 可以导出常量、函数和类: + +```squirrel +local nextId = 1 // 模块私有 + +export const DEFAULT_HEALTH = 100 + +export class ActorId { + value = 0 + constructor(value) { this.value = value } +} + +export function allocate_actor_id() { + return ActorId(nextId++) +} +``` + +`export` 只能用于模块顶层的 `const`、`function` 或 `class` 声明。局部变量不会泄漏给导入方。 + +### 3.2 导入 + +```squirrel +import { ActorId, allocate_actor_id } from "game:/scripts/ids.nut" +import { allocate_actor_id as allocate_id } from "./ids.nut" +import * as ids from "./ids.nut" + +local first = allocate_actor_id() +local second = allocate_id() +local third = ids.allocate_actor_id() +``` + +模块说明符必须是字符串字面量: + +- `game:/...` 指向游戏包中的脚本; +- `engine:/...` 指向 SDK 标准脚本库; +- `plugin:/...` 指向插件公开的脚本模块; +- `./...` 和 `../...` 相对于当前模块解析,但不能越出所属根目录。 + +同一规范 URI 在一次 Runtime generation 中只实例化一次。多处导入不会重复执行顶层代码。 +循环导入会在编译依赖图时被拒绝;把共享类型或常量提取到第三个无环模块即可。 + +模块 URI 与物理文件格式无关,所以同一条 import 在开发目录、`.eve` ZIP、Android APK、iOS +bundle 和 Web 预加载文件系统中保持一致。打包器会扫描依赖图;动态拼接路径不是合法 import。 + +### 3.3 与 `dofile` 的区别 + +`dofile(path)` 仍为旧 Squirrel 脚本保留,但它表达的是“读取并执行文件”,没有私有命名空间、 +显式导出、单次实例化缓存或静态依赖图。新项目应使用 `import`;只有迁移旧脚本或确实需要传统 +共享 root-table 副作用时才使用 `dofile`。 + +## 4. 渐进类型 + +类型标注是可选的。未标注代码继续使用 Squirrel 动态语义;标注不会把值包装成新的运行时对象。 + +```squirrel +local speed: float = 180.0 +local name: string = "Ada" +local targets: Array = [] +local scores: Table = {} + +function damage(amount: int, critical: bool) -> int { + return critical ? amount * 2 : amount +} +``` + +基础类型为 `null`、`bool`、`int`、`float`、`string` 和 `dynamic`。容器使用 `Array`、 +`Table`;引擎绑定类使用文档中的脚本类名。 + +类型是“渐进式静态检查”,不是完整的运行时类型系统: + +- 字面量与已知函数调用会在编译期检查; +- 原生方法依赖 SDK 生成的 Binding Contract 检查参数名、类型和单位; +- `dynamic` 或无法推断的旧代码仍可能在运行时失败; +- 类型标注在生成字节码前擦除,普通局部值仍是原始 Squirrel 值。 + +例如: + +```squirrel +local count: int = "many" // 编译错误 + +function set_count(value: int) {} +set_count(1.5) // 编译错误 +``` + +## 5. Nullable 与空值运算 + +`T?` 表示值可以为 `null`: + +```squirrel +local target: Actor? = find_target() +``` + +常用运算符: + +```squirrel +target?.damage(10) // target 为 null 时不调用,结果为 null +local label = target?.name ?? "None" // 左侧为 null 才计算右侧 +target ??= find_fallback_target() // 仅在 target 为 null 时赋值 +``` + +接收者和左操作数只求值一次,`??` 具有短路语义: + +```squirrel +local target = cached ?? expensive_lookup() +``` + +当 `cached` 非空时,`expensive_lookup()` 不会执行。成员 slot 的 `??=` 也受支持: + +```squirrel +local state = { checkpoint = null } +state.checkpoint ??= make_checkpoint() +``` + +非 nullable 类型不能直接赋 `null`。来自旧代码的动态值仍应在原生资源、场景查询等边界做好错误 +处理。 + +## 6. 字符串取值类型 + +EVEngine 的模式、类型和策略名称在 C++ 边界大量使用字符串。EveScript 用字符串字面量联合约束 +合法值,而不强制把它们改成整数枚举: + +```squirrel +local movement: "idle" | "run" | "jump" = "idle" + +function set_surface(mode: "opaque" | "mask" | "blend") { + material.setSurfaceMode(mode) +} +``` + +好处是补全、拼写检查、`match` 穷尽检查和 Inspector 下拉选项都能共享同一组值;传入 C++、 +JSON 或日志时仍是普通字符串。 + +```squirrel +set_surface("transparent") // 已知字面量不在集合中,编译错误 +``` + +## 7. 命名参数 + +调用时用 `name: value` 指明参数: + +```squirrel +local world = physics.newWorld( + gravityY: 980.0, + sleep: true, + gravityX: 0.0 +) +``` + +编译器根据脚本函数声明或原生 Binding Contract 还原成位置调用,因此不会创建参数表,也没有额外 +逐帧分配。参数可以重排;未知、重复或缺失参数是编译错误。 + +```squirrel +function blend(from: float, to: float, weight: float) -> float { + return from + (to - from) * weight +} + +local value = blend(weight: 0.25, from: 0.0, to: 1.0) +``` + +参数表达式仍按源码出现顺序求值,而不是按声明顺序求值。不要依赖重排后的隐藏副作用: + +```squirrel +compose(third: log_and_return(3), first: log_and_return(1), second: log_and_return(2)) +// 调用日志顺序仍是 3、1、2;传给函数的槽位顺序则是 first、second、third。 +``` + +无法取得可靠 Binding Contract 的动态原生函数不能使用命名参数,改用位置参数。 + +SDK 构建会从当前 profile 中实际参与编译的 SimpleSquirrel `addFunc` 声明生成契约。无法解析真实 +参数名的绑定会直接使生成步骤失败,不会退化成 `arg0`、`arg1`。生成结果在 `ScriptCompiler` +创建时注册到 SimpleSquirrel 的 named-argument resolver,因此编译检查、补全、Hover 与运行时 +使用的是同一份契约。C++ 默认实参不会自动成为脚本默认值;不同类上同名且签名有歧义的方法应 +继续使用位置参数。契约类型、字符串选项和单位检查只约束参数表达式边界上的直接字面量;合法的 +复合表达式(例如 `ui.setText("status", "count " + (count + 1))`)会按正常表达式语义编译, +不会把内部数值字面量误判成字符串参数。 + +## 8. Inspector 属性注解 + +注解是 Squirrel attribute 的短写,放在类字段之前: + +```squirrel +class CharacterData { + @editor("slider", min: 0, max: 100, step: 1) + @unit("hp") + health: int = 100 + + @editor("combo") + job: "warrior" | "mage" | "rogue" = "warrior" + + @editor("text") + displayName: string = "Ada" +} +``` + +它等价于已有的 Squirrel attribute 元数据,Inspector、反射和 MCP 读取的仍是同一份信息。字符串 +取值类型配合 `@editor("combo")` 时会自动生成选项。 + +内置注解拼写错误会在编译期报告。插件注解必须先由插件注册,例如插件可能提供 +`@plugin_asset("texture")`;未注册的未知注解不能静默忽略。 + +## 9. 热重载持久变量:`persist` + +普通变量在脚本重载后没有保留承诺。需要保留的模块/root 状态显式声明为 `persist`: + +```squirrel +persist score: int = 0 +persist session: Table = {} +``` + +初始化表达式只在该名称第一次出现时执行: + +```squirrel +persist world = create_world() // 后续热重载不会再次调用 create_world() +``` + +规范: + +- `persist` 只允许在文件 root scope; +- 名称在 root table 中必须唯一; +- 未写 `persist` 的变量就是普通临时脚本状态,不需要 `transient`; +- 优先保存普通数据;无法安全快照的原生句柄应由对应引擎模块的状态提供器管理; +- 修改持久数据结构时,要在 `eve_reload()` 中做显式版本迁移或回退。 + +旧式写法仍兼容: + +```squirrel +score <- persist("score", function() { return 0 }) +``` + +新代码使用声明形式,避免名称字符串与变量名不一致。 + +## 10. 单位字面量 + +时间、角度和空间量可以直接携带单位: + +```squirrel +local fade: seconds = 250ms // 0.25 +local turn: radians = 90deg // π / 2 +local tileSize: pixels = 32px // 32.0 +local height: meters = 1.8m // 1.8 +``` + +v1 支持的量纲和后缀: + +| 量纲 | 目标类型 | 字面量后缀 | 说明 | +|---|---|---|---| +| 时间 | `seconds`、`milliseconds` | `ms` | `250ms` 转为 `seconds` 时是 `0.25` | +| 角度 | `radians`、`degrees` | `deg` | `90deg` 转为 `radians` 时约为 `1.5708` | +| 像素 | `pixels` | `px` | 保持浮点标量 | +| 距离 | `meters` | `m` | 保持浮点标量 | + +原生 Binding Contract 声明目标单位后,调用处会自动进行确定的换算: + +```squirrel +camera.setYaw(90deg) // 若参数契约为 radians,编译为弧度标量 +asyncSleep(250ms) // asyncSleep 接收毫秒,数值为 250.0 +``` + +没有目标类型或绑定契约时,字面量采用自身的基础单位数值;不同量纲不能混传。单位只参与编译期 +检查与常量换算,运行时仍是普通 float。 + +## 11. `match` 模式匹配 + +v1 的 `match` 是语句,不是返回值的表达式: + +```squirrel +function update_animation(mode: "idle" | "run" | "jump") { + match mode { + "idle" => animation.play("idle") + "run" => animation.play("run") + "jump" => animation.play("jump") + } +} +``` + +分支可以是单条语句或代码块: + +```squirrel +match eventName { + "damage" => { + flash_red() + play_hit_sound() + } + "heal" => show_heal_effect() + else => print("unknown event: " + eventName + "\n") +} +``` + +规则: + +- 字符串取值联合的所有选项都覆盖时,可以省略 `else`; +- 未穷尽联合类型必须提供剩余分支,否则编译错误; +- 动态值无法静态证明穷尽,因此必须有 `else`; +- `else` 只能出现一次且必须是最后一个分支; +- 被匹配的表达式只求值一次。 + +需要产生值时,在各分支中赋给外部局部变量或直接 `return`;不要写成表达式赋值。 + +## 12. 异步编程:`async function` 与 `await` + +EveScript 异步建立在 EVEngine 现有 Promise、计时器、事件队列和主线程 pump 上: + +```squirrel +async function intro_sequence() -> string { + await asyncSleep(250ms) + ui.setText("message", "Ready") + + local values = await Promise.all([ + load_profile_async(), + load_inventory_async() + ]) + return values[0].name +} + +intro_sequence().then( + function(name) { print("welcome " + name + "\n") }, + function(error) { print("intro failed: " + error + "\n") } +) +``` + +语义: + +- 调用 `async function` 立即返回 Promise; +- `await value` 会用 `Promise.resolve(value)` 统一普通值和 Promise; +- Promise 完成后,continuation 在游戏线程的异步队列恢复; +- `try` / `catch` 可以捕获被拒绝 Promise 的原因; +- `return value` 完成外层 Promise,未捕获异常会拒绝它; +- `await` 只能出现在 `async function` 内; +- 异步函数不会把 Squirrel VM 移到 worker 线程。 + +循环、局部变量、`this` 和异常状态会跨 `await` 保存: + +```squirrel +class Counter { + offset = 4 + + async function compute(limit: int) -> int { + local total = this.offset + for (local i = 0; i < limit; ++i) + total += await Promise.resolve(i) + + try { + await Promise.reject("expected") + } catch (error) { + total += 10 + } + return total + } +} +``` + +引擎帧循环会自动调用 `async_pump()`,游戏代码通常不应手动 pump。脚本热重载会取消旧 generation +尚未恢复的 continuation,防止旧闭包在新代码和新状态上继续运行;需要长期可恢复的任务时,应把 +进度保存为普通数据,并在重载后重新启动任务。 + +Promise 不等于后台线程。CPU 密集任务应使用 thread 模块提供的受支持 worker 操作,再通过事件、 +Channel 或 Promise 回到主线程;worker 不得访问 Squirrel VM、窗口、GPU 或脚本对象。 + +当文件监听器发现已导入模块变化时,Runtime 会重编译该模块及反向依赖它的全部缓存 generation, +并在整组实例化成功后一次性提交。任一依赖编译或实例化失败都会恢复旧模块和旧依赖图;未进入 +模块图的兼容性 root 脚本仍沿用软重载路径。 + +## 13. 与响应式事件配合 + +`async` / `await` 适合有明确开始、等待和结束的流程;事件或 Rx 适合持续数据流。不要把所有异步 +逻辑都写成永久等待的任务: + +- 一次加载、延迟、过场流程:`async function`; +- 多个监听者的离散游戏事件:event 模块; +- 输入、状态或事件的持续组合与过滤:rx 模块; +- 跨线程工作:thread + Channel/Event,最终回到主线程。 + +具体接口见[事件模块](modules/event.md)、[响应式编程模块](modules/rx.md)和 +[线程与异步模块](modules/thread.md)。 + +## 14. 生命周期、模块与推荐组织方式 + +生命周期回调仍使用引擎约定的 root slot: + +```squirrel +eve_init <- function() {} +eve_update <- function(dt) {} +eve_render <- function() {} +eve_reload <- function() {} +eve_asset_reload <- function(path) {} +eve_quit <- function() {} +``` + +推荐把 `main.nut` 保持为编排层:导入系统、创建顶层持久状态、连接生命周期。具体玩法逻辑放进 +各自模块: + +```text +scripts/ +├── player.nut +├── combat.nut +├── ui/ +│ └── hud.nut +└── world/ + └── dungeon.nut +``` + +模块建议: + +- export 少量稳定的类和函数,不导出可随意修改的内部表; +- 避免模块顶层产生不可重复的外部副作用,把初始化放进显式函数; +- 模块间保持有向无环依赖;共享定义放到更底层的小模块; +- 需要跨重载的数据用 `persist`,可重建缓存则保持普通变量; +- 必需引擎模块写进 `config.modules`,可选功能同时使用 `optionalModules` 和 `has_module()`。 + +可选模块示例: + +```squirrel +if (has_module("audio")) { + // audio 是当前构建注入的全局 Audio 实例;Source 由它创建并播放。 + audio.stopAll() +} +``` + +## 15. 错误处理和编码规范 + +推荐规范: + +1. 文件名使用小写 `snake_case.nut`,模块 URI 使用 `/`; +2. 局部变量和函数使用 `snake_case`,类使用 `PascalCase`,导出常量使用 `UPPER_SNAKE_CASE`; +3. 默认使用 `local`,跨文件接口使用 `export`,不要把普通状态随意写入 root table; +4. 公共函数、引擎边界和持久状态优先写类型;短小内部动态代码可以不标注; +5. 字符串模式使用联合类型,不为方便而引入脚本整数枚举; +6. 多个相邻数字或布尔参数优先使用命名参数; +7. 时间、角度和空间参数优先携带单位; +8. 对可恢复的资源错误使用 `try` / `catch`,不要吞掉编程错误; +9. 每帧热路径避免临时大数组、动态 import 和不必要的跨 C++/Squirrel 细粒度调用; +10. 异步任务必须考虑失败、退出和热重载取消。 + +原生绑定遇到非法字符串选项、参数类型错误、空对象或资源不存在时通常会抛 Squirrel 异常。对可 +恢复错误: + +```squirrel +try { + load_level("levels/forest.json") +} catch (error) { + print("cannot load forest: " + error + "\n") + load_level("levels/fallback.json") +} +``` + +## 16. EveScript 与标准 Squirrel 的区别 + +| 能力 | 标准 Squirrel | EveScript | +|---|---|---| +| 文件 | `.nut` | 仍为 `.nut`,全项目默认启用 | +| 模块 | 通常用 `dofile` 和共享 root table | `import` / `export`、私有环境、缓存和静态依赖图 | +| 类型 | 动态类型 | 可选渐进类型,编译后擦除 | +| 空值 | 手写 `x == null` | `T?`、`?.`、`??`、`??=` | +| 有限选项 | 字符串或整数常量 | 字符串字面量联合,运行时仍是字符串 | +| 调用参数 | 位置参数 | 已知签名支持 `name: value` | +| 字段元数据 | `` attribute | `@editor(...)`、`@unit(...)` 短写 | +| 热重载状态 | 手写 root 判断或 `persist(name, fn)` | `persist name = initializer` 声明 | +| 单位 | 裸数字 | `250ms`、`90deg`、`32px`、`1.8m` | +| 分支 | `if` / `switch` | 额外提供可穷尽检查的 `match` 语句 | +| 异步 | generator、thread、Promise 链 | 额外提供 Promise 状态机 `async` / `await` | +| VM 与对象模型 | Squirrel VM | 不变,继续使用同一 VM 和原生绑定 | + +EveScript 没有增加以下概念: + +- 没有文件级语言版本标记; +- 没有默认不可变变量,也没有 `let` / `var` 分裂; +- 没有要求所有变量写类型; +- 没有把普通变量标成 `transient`;未写 `persist` 就是普通变量; +- 没有隐藏 ECS 的新 DSL,ECS 继续通过 `eve.Component`、`eve.Entity`、`eve.System` 和模块 API; +- 没有统一的 Signal 关键字,事件、Rx、Promise 和状态绑定各自承担适合的职责; +- 没有独立虚拟机或 TypeScript 式外部转译运行时。 + +## 17. 从现有 Squirrel 项目迁移 + +迁移可以逐文件进行,因为标准 Squirrel 是 EveScript 的兼容子集。 + +### 第一步:直接运行并建立基线 + +不要先机械重写。升级 SDK 后先运行现有游戏,修复真实的不兼容或绑定变化。原有类、闭包、 +generator、table、array、attribute 和 `dofile` 都仍然可用。 + +### 第二步:声明项目模块 + +把所有脚本无条件使用的模块写入 `config.modules`;有降级路径的模块写入 +`config.optionalModules` 并保留 `has_module()`。 + +### 第三步:用 import/export 替换文件级共享副作用 + +旧代码: + +```squirrel +// player.nut +Player <- class {} + +// main.nut +dofile("scripts/player.nut") +local player = Player() +``` + +新代码: + +```squirrel +// scripts/player.nut +export class Player {} + +// main.nut +import { Player } from "./scripts/player.nut" +local player = Player() +``` + +### 第四步:只给稳定边界补类型 + +先标注导出函数、持久状态、Inspector 字段和原生 API 边界,不必一次给所有局部变量加类型。 + +### 第五步:替换重复样板 + +- root-table 热重载判断改成 `persist`; +- 判空链改成 `?.` / `??`; +- 多个同类型参数改成命名参数; +- 毫秒、角度和距离裸数改成单位字面量; +- Promise 链较深且有顺序流程时改成 `async` / `await`。 + +每一步都保持游戏可运行,并单独验证热重载和打包结果。 + +## 18. 工具、诊断和调试 + +桌面 SDK 提供 EveScript Language Server: + +```sh +eve language-server --root /path/to/my-game +``` + +它通过标准输入输出使用 LSP 协议,供编辑器插件启动;不要在同一 stdio 通道输出额外日志。当前 +能力包括: + +- 语法和结构化诊断; +- `config.modules` / `optionalModules` 检查; +- 已知脚本符号与 Binding Contract 补全; +- 原生方法签名和文档 Hover; +- 跨文件 `import` / `export` 定义跳转; +- 项目级引用查找; +- 导出符号和 import alias 的安全重命名; +- 文档打开、增量更新和关闭同步。 + +运行时调试: + +```sh +eve run --debug --dap-port=4711 my-game +``` + +EveScript 编译器为扩展语法生成 Source Map,断点、停止位置、调用栈和异常会映射回原始 `.nut` +源码,而不是显示内部状态机位置。打包前可运行: + +```sh +eve zip my-game +``` + +打包器会验证脚本模块图,缺失模块、循环依赖或越界 URI 会在生成归档前失败,并在包内写入脚本 +模块清单。 + +常见诊断处理: + +- `await is only allowed inside async function`:给所在函数加 `async`,或改用 Promise 链; +- `named arguments require a known function or Binding Contract`:改用位置参数,或确认 SDK 绑定契约; +- `non-exhaustive match`:补齐字符串联合选项,或添加最终 `else`; +- `outside the allowed choices`:使用联合类型或绑定契约列出的字符串; +- `persist is only allowed at root scope`:把声明移到模块顶层; +- `required module is missing`:更换包含该模块的 SDK/profile,或移入 `optionalModules` 并实现降级; +- 模块循环:提取共享定义,保持依赖图有向无环。 + +## 19. 完整示例 + +下面把模块、类型、nullable、持久状态、单位、match、命名参数和异步流程组合在一起。 + +`scripts/player.nut`: + +```squirrel +export class PlayerState { + @editor("slider", min: 0, max: 100, step: 1) + health: int = 100 + + @editor("combo") + mode: "idle" | "run" | "hurt" = "idle" + + x: float = 100.0 +} + +export function update_player( + player: PlayerState, + dt: float, + speed: float +) { + if (keyboard.isDown("right")) { + player.x += speed * dt + player.mode = "run" + } else { + player.mode = "idle" + } +} + +export function render_player(player: PlayerState) { + match player.mode { + "idle" => gfx.drawSolidRect(player.x, 220.0, 48.0, 48.0, 0.3, 0.75, 1.0, 1.0) + "run" => gfx.drawSolidRect(player.x, 220.0, 48.0, 48.0, 0.2, 1.0, 0.5, 1.0) + "hurt" => gfx.drawSolidRect(player.x, 220.0, 48.0, 48.0, 1.0, 0.2, 0.2, 1.0) + } +} +``` + +`main.nut`: + +```squirrel +import { PlayerState, update_player, render_player } from "./scripts/player.nut" + +persist player: PlayerState = PlayerState() +local notice: string? = null + +async function show_ready_message() { + notice = "Get ready" + await asyncSleep(750ms) + notice = "Go!" + await asyncSleep(500ms) + notice = null +} + +eve_init <- function() { + gfx.setBackgroundColor(0.08, 0.10, 0.16, 1.0) + show_ready_message().then( + function(_) {}, + function(error) { print("message sequence failed: " + error + "\n") } + ) +} + +eve_update <- function(dt: float) { + update_player(player: player, speed: 180.0, dt: dt) +} + +eve_render <- function() { + gfx.clear() + render_player(player) + // 文本绘制需要先通过 font 模块创建字体;这里用色条表示异步提示仍在显示。 + if (notice != null) + gfx.drawSolidRect(32.0, 32.0, 180.0, 8.0, 1.0, 0.8, 0.2, 1.0) +} +``` + +实际项目中的绘制和输入函数请以当前 SDK 的[模块使用手册](MODULES.md)为准。学习顺序建议是: +先掌握 Squirrel 的 table、array、class 和闭包,再使用模块与类型建立代码边界,最后在确有等待流程 +时引入 `async` / `await`。 diff --git a/docs/usr/README.md b/docs/usr/README.md index 186f0564..3262fbf0 100644 --- a/docs/usr/README.md +++ b/docs/usr/README.md @@ -7,7 +7,7 @@ EVEngine 是一个以 Squirrel 脚本驱动的轻量游戏引擎,适合快速 > 当前版本为早期开发版本,API 仍在演进。最可靠的学习方式是运行并修改示例:下载的 SDK 自带 `share/eve/examples/basic/`,仓库中还有更多 `examples/` 可参考。 -本指南从“下载引擎”讲到“打包发布”;覆盖全部脚本模块的[模块使用手册](MODULES.md)按功能逐章说明 API 与示例。先跑通一个示例,再按需查阅对应模块即可。 +本指南从“下载引擎”讲到“打包发布”;[EveScript 完整教程](EVESCRIPT.md)讲解脚本语法、规范和与标准 Squirrel 的区别,覆盖全部脚本模块的[模块使用手册](MODULES.md)则按功能逐章说明 API 与示例。先跑通一个示例,再按需查阅对应文档即可。 ## 1. 获取引擎:从官网下载,无需编译 @@ -103,7 +103,7 @@ eve_render <- function() { my-game/ ├── config.nut ├── main.nut -├── scripts/ # 拆分后的脚本,通过 dofile(...) 加载 +├── scripts/ # 拆分后的 EveScript 模块,通过 import 加载 ├── maps/ # Tiled JSON 或地图数据 ├── particles/ # 粒子 JSON ├── textures/ @@ -127,10 +127,10 @@ my-game/ | `eve_asset_reload(path)` | 非脚本资源变化后(可选) | 响应纹理、地图等资源变化 | | `eve_quit()` | 退出时(可选) | 保存数据或执行清理 | -启用 `config.hotReload` 后,引擎会监视脚本和资源。热重载会再次执行脚本,因此应避免无条件重置全局状态。推荐模式: +启用 `config.hotReload` 后,引擎会监视脚本和资源。需要跨热重载保存的 root 状态使用 EveScript 的 `persist` 声明: ```squirrel -if (!("player" in getroottable())) player <- null; +persist player = null function eve_init() { if (player == null) { @@ -140,7 +140,7 @@ function eve_init() { } ``` -把一次性初始化放进 `eve_init`,把可重复执行的声明保留在顶层,并参考 `share/eve/examples/basic/main.nut` 中的完整写法。 +初始化表达式只在持久名称第一次出现时执行。把一次性初始化放进 `eve_init`,把可重复执行的声明保留在顶层;完整语义与迁移方法见 [EveScript 完整教程](EVESCRIPT.md)。 ## 4. 你能用引擎做哪些事 @@ -292,7 +292,7 @@ config.devServer = "http://192.168.1.5:8765" # 开发机局域网 IP 1. 无需编译,但需要 Vulkan 驱动:运行时基于 Vulkan。Windows 显卡驱动通常自带 Vulkan 运行时;macOS 由 SDK 内置 MoltenVK;Linux 需要安装 Vulkan 驱动(`mesa-vulkan-drivers` 或厂商驱动)。如果游戏启动后窗口黑屏或报 Vulkan 初始化失败,优先检查驱动。 2. SDK 按平台独立、版本必须匹配:每个 SDK 只面向一个目标平台;`eve package`、原生插件必须与 SDK 的平台、版本一致,不同平台的 SDK 不能混用。 3. 脚本 API 以“模块手册 + 示例”为准:C++ 头文件中的 public 方法不一定是脚本 API;调用约定见[API 使用约定](../dev/API-CONVENTIONS.md)。 -4. 热重载会重新执行脚本:用 `if (!("x" in getroottable()))` 保护需要跨重载保留的全局引用(见第 3 节)。 +4. 热重载会重新执行脚本:用 EveScript 的 `persist` 声明需要跨重载保留的 root 状态(见第 3 节和 [EveScript 完整教程](EVESCRIPT.md))。 5. 模块可以裁剪,但默认全开:脚本里用 `has_module("slot")` 判断模块是否存在;构建时裁剪只对“从源码构建”的用户有效,下载的 SDK 默认包含全部模块(见[按需裁剪模块](TRIMMING.md))。 6. 许可:EVEngine 采用双许可,商用营收超过免费门槛后需商业授权,详见根目录 [许可证](https://github.com/EVEngine/EVEngine/blob/main/Readme.md#许可证)。 7. 版本与分支:Release 与 `main` 分支对应正式版;早期开发版 API 可能变化,升级 SDK 后请先运行内置示例回归。 @@ -307,8 +307,9 @@ config.devServer = "http://192.168.1.5:8765" # 开发机局域网 IP 1. 运行 `share/eve/examples/basic/`,修改背景色、重力和粒子配置; 2. 用 `eve create` 建新游戏,熟悉 `eve_init` / `eve_update` / `eve_render` 与热重载; -3. 按需求查阅[模块使用手册](MODULES.md)(UI、ECS、RPG、程序化生成等); -4. 需要原生能力时,再使用 SDK 的 CMake 包编写插件(见仓库 `examples/native-plugin`); -5. 发布前阅读第 6 节打包步骤与许可条款。 +3. 阅读 [EveScript 完整教程](EVESCRIPT.md),再把脚本拆成显式 `import` / `export` 模块; +4. 按需求查阅[模块使用手册](MODULES.md)(UI、ECS、RPG、程序化生成等); +5. 需要原生能力时,再使用 SDK 的 CMake 包编写插件(见仓库 `examples/native-plugin`); +6. 发布前阅读第 6 节打包步骤与许可条款。 本指南讲解稳定的使用流程,内部架构、设计取舍和实现进度统一维护在 [`docs/dev/`](https://github.com/EVEngine/EVEngine/blob/main/docs/dev/README.md),避免把尚未实现的设计稿误当作用户 API。 diff --git a/docs/usr/modules/hd2d.md b/docs/usr/modules/hd2d.md new file mode 100644 index 00000000..b5f9912c --- /dev/null +++ b/docs/usr/modules/hd2d.md @@ -0,0 +1,82 @@ +# HD-2D 场景渲染 + +HD-2D 模块把 2D 内容叠加到现有 3D 渲染管线,实现「2D tilemap 拼 3D 场景、2D 角色在 3D 世界里作为立体广告牌」的效果。它包含两类对象: + +- `TileMap3D`:把 2D tilemap(`map` 模块的 `TileLayer`)挤成 3D 地形网格。 +- `Sprite3D`:把 2D 精灵图/角色帧作为始终正对相机的 billboard 渲染进 3D 场景,支持精灵表帧动画。 + +模块绑定在 `eve.Hd2D`,脚本槽位为 `hd2d`。 + +## TileMap3D:2D tilemap → 3D 场景 + +`TileMap3D.buildRenderable(gfx, layer)` 遍历 tile layer,把每个非空格子生成一个方块:顶面采样该格子的图集区域,侧壁向下挤出到配置深度。逐格高度从 tile 的 `"height"` 自定义数据读取。返回的 `Renderable3D` 自动接入深度、阴影与 GBuffer。 + +```squirrel +local tile = hd2d.newTileMap3D(); +tile.setSideDepth(6.0); // 侧壁挤出深度(世界单位) +tile.setHeightScale(8.0); // 每 1 单位 "height" 元数据对应的高度 +tile.setWallUV(0.0, 0.0, 0.05, 0.05); // 侧壁采样的图集区域 +tile.setTint(1.0, 1.0, 1.0, 1.0); + +local layer = map.newLayerFromFile("assets/field.json"); // 现有 2D tilemap +local ground = tile.buildRenderable(gfx, layer); +ground.setPosition(0.0, 0.0, 0.0); + +local count = tile.getTileCount(); // 已烘焙的格子数 +local depth = tile.getSideDepth(); // 读取当前侧壁深度 +local scale = tile.getHeightScale(); // 读取当前高度缩放 +``` + +也可以只生成网格、自行接管:`tile.buildMesh(gfx, layer)` 返回 `Mesh`(图集纹理来自 layer 的 tileset)。空的 layer 返回 `null`。 + +## Sprite3D:2D 角色/动画作为 3D billboard + +`Sprite3D` 是一张在 3D 世界里始终正对相机的四边形(圆柱 billboard:只绕世界 Y 轴转向相机、保持竖直),承载一张 2D 纹理(角色立绘或动画帧)。它通过 `eve` 的 `Renderable3D` 进入前向 pass,因此能和地形正确合成并投射阴影。 + +```squirrel +local hero = hd2d.newSprite(gfx); +hero.setTexture(gfx.newTextureFromFile("assets/hero.png")); +hero.setPosition(192.0, 16.0, 128.0); // 世界坐标 +hero.setSize(28.0, 56.0); // billboard 尺寸 +hero.setTint(1.0, 1.0, 1.0, 1.0); // 颜色倍率(alpha 参与裁切) +hero.setVisible(true); +hero.setCamera(cam); // 绑定相机后 update() 每帧转向相机 + +local tx = hero.getPositionX(); local ty = hero.getPositionY(); local tz = hero.getPositionZ(); +local w = hero.getWidth(); local h = hero.getHeight(); +local vis = hero.getVisible(); +local tex = hero.getTexture(); +``` + +### 精灵表帧动画 + +把一张精灵表按网格切帧,用 `play`/`update` 播放,2D 动画就能在 3D 里动起来: + +```squirrel +hero.setFrameGrid(4, 2); // 4 列 × 2 行的精灵表 +hero.setFrameIndex(0); // 直接跳到某个帧(0 起) +hero.play(0, 5, 12.0); // 播放第 0..5 帧,每秒 12 帧 +hero.setFlipX(true); // 水平翻转当前帧 +hero.setFlipY(false); // 垂直翻转 + +function eve_update(dt) { + hero.update(dt); // 推进动画时钟 + 转向相机 + if (!hero.isPlaying()) hero.play(0, 5, 12.0); +} +``` + +```squirrel +local cols = hero.getFrameGridColumns(); // 精灵表列数 +local rows = hero.getFrameGridRows(); // 精灵表行数 +local total = hero.getFrameCount(); // 总帧数(列 × 行) +local idx = hero.getFrameIndex(); // 当前帧序号 +hero.stop(); // 暂停动画 +``` + +直接指定任意图集子矩形:`hero.setFrame(u0, v0, u1, v1)`。 + +## 模块方法与对象 + +- `hd2d.getName()`、`hd2d.newTileMap3D()`、`hd2d.newSprite(gfx)` +- `TileMap3D`:`setSideDepth`/`getSideDepth`、`setHeightScale`/`getHeightScale`、`setWallUV`、`setTint`、`buildMesh`、`buildRenderable`、`getTileCount` +- `Sprite3D`:`setTexture`/`getTexture`、`setFrame`、`setFlipX`/`setFlipY`、`setFrameGrid`、`getFrameGridColumns`/`getFrameGridRows`、`setFrameIndex`/`getFrameIndex`/`getFrameCount`、`play`/`stop`/`isPlaying`/`update`、`setPosition`/`getPositionX`/`getPositionY`/`getPositionZ`、`setSize`/`getWidth`/`getHeight`、`setTint`、`setVisible`/`getVisible` \ No newline at end of file diff --git a/docs/usr/modules/procgen.md b/docs/usr/modules/procgen.md index 705d127d..cf2bbbc4 100644 --- a/docs/usr/modules/procgen.md +++ b/docs/usr/modules/procgen.md @@ -16,6 +16,17 @@ C++ render bridge 的 borrowed 边界,不是 Squirrel 的第二套生成入口 Procgen 不提供 `lastError()`、`*Owned` 或 `*Checked` 的兼容命名。Result 的 `value` 可以是由 generation handle 支持的 owned proxy,释放和 stale 检查遵循该 proxy 的 公共方法。 +## UE PCG 对标范围 + +本模块对标的是 UE PCG 的核心工作流,而不是复制 UE 类型或资产格式:统一 Spatial Data、 +带属性 Point Data、可缓存 Point Graph/子图、分区与 Hierarchical Generation、多 Generation +Source、视锥与时间预算、Biome Rules、Shape Grammar,以及到 Scene 的可撤销实例批次均有 +对应实现。编辑器侧使用通用 `GraphDocument` 的 `procgen.point` domain,编译为版本化运行时 +PointGraph 定义。 + +当前执行器是确定性的 CPU 实现;没有照搬 UE 的 GPU PCG Compute Graph。Scene sink 发布 +稳定节点与资产 tag,具体模型加载/渲染仍由项目的 scene/graphics 资产系统消费。PointGraph +保持无环数据流,迭代算法应封装为一个 operation 或子图,而不是创建反馈边。 ## 基本用法 @@ -223,6 +234,333 @@ local wilderness = wildernessResult.value; 不要让不同内容共享一个可变随机流。用 `deriveSeed(root, "trees")`、 `deriveSeed(root, "rocks")` 为分支派生稳定 seed;修改岩石管线不会扰动树木结果。 +### 统一空间数据与集合运算 + +`ProcgenSpatialData` 为点、体积和样条提供统一的世界空间查询。它不引用 scene 或 +graphics,因此生成逻辑可以在无头测试、编辑器预览和运行时流送中复用。当前支持 +Box、Sphere、Spline、Point Data,以及 Union、Intersection、Difference 三种组合; +组合对象会保存输入的不可变副本,调用方可以安全销毁原对象: + +```nut +local world = procgen.boxVolume(0, -20, 0, 512, 200, 512); +local town = procgen.sphereVolume(256, 0, 256, 80); + +local roadPoints = procgen.newPointSet(); +roadPoints.add(20, 0, 30); +roadPoints.add(180, 0, 220); +roadPoints.add(480, 0, 420); +local road = procgen.splineData(roadPoints, 8.0); + +local reserved = procgen.unionSpatial(town, road); +local wilderness = procgen.differenceSpatial(world, reserved); +local candidates = procgen.sampleSpatial(wilderness, 16.0, + procgen.deriveSeed(42, "wilderness"), 0.7); +``` + +`sampleSpatial(spatial, spacing, seed, jitter)` 在空间包围盒内建立确定性三维格点, +然后使用真实空间查询剔除外部候选。相同输入会得到相同位置和 point seed。 +`filterSpatial(points, spatial, false)` 保留内部点;最后一个参数为 `true` 时保留外部点。 +可用 `getKind`、`contains`、`hasBounds`、`getMinX/Y/Z` 和 `getMaxX/Y/Z` 检查空间值。 + +高度图可包装为 `surface.heightfield`,成为与体积、样条相同的一等空间输入。 +`sampleSpatial` 对高度面只在 XZ 平面产生候选并自动写入高度和地表法线;已有点集可用 +`projectToSpatial` 投影: + +```nut +local surface = procgen.heightfieldData(heightmap, 0, 0, 2.0, 80.0); +local ground = procgen.sampleSpatial(surface, 8.0, 42, 0.5); +local projected = procgen.projectToSpatial(importedPoints, surface); +``` + +常用 PCG 点处理以返回新值的纯函数提供:`mergePoints`、`transformPoints`、 +`filterFloatAttribute`、`filterStringAttribute` 和 `densityCull`。属性过滤最后一个参数 +是 invert;`densityCull(points, seed, multiplier)` 使用 point density 和稳定 seed, +不会因其它分支新增随机调用而改变结果。 + +### 分区、层级与运行时生成 + +`newRuntimeGeneration(worldSeed)` 创建与场景/渲染解耦的流送调度器。每个 level +声明 cell size、生成半径和清理半径倍率;大格适合道路、山体和乔木,小格适合草、 +碎石等近景细节。生成与清理使用不同半径以避免边界抖动: + +```nut +local runtime = procgen.newRuntimeGeneration(42); +runtime.addLevel(256.0, 900.0, 1.25); // large shared work +runtime.addLevel(64.0, 260.0, 1.5); // nearby details +runtime.setMaxGenerating(4); +runtime.setMaxActiveCells(2048); // active + in-flight resident budget +runtime.setMaxPointsPerCell(200000); // protect against pathological graph output +runtime.setMaxResidentPoints(4000000); // hard PointSet cache budget +runtime.setMaxGenerationRetries(3); +runtime.setDirectionWeight(0.35); +runtime.setFrameTimeBudget(3.0); + +function updateRuntime(playerX, playerZ, forwardX, forwardZ) { + runtime.updateSource(playerX, playerZ, forwardX, forwardZ); + runtime.beginFrame(); + for (local request = runtime.nextGenerate(); request != null; + request = runtime.nextGenerate()) { + try { + local cell = buildCell(request.getLevel(), request.getMinX(), request.getMinZ(), + request.getMaxX(), request.getMaxZ(), request.getSeed()); + if (!runtime.completeGeneration(request, cell)) throw "stale PCG cell request"; + } catch (error) { + runtime.failGeneration(request); // 保留相同 cell seed,稍后重试 + } + } + for (local request = runtime.nextCleanup(); request != null; + request = runtime.nextCleanup()) { + removeCellInstances(request.getLevel(), request.getX(), request.getZ()); + runtime.completeCleanup(request); + } +} +``` + +队列按距离与视线朝向排序,同优先级使用 level/z/x 稳定打破平局,保证相同输入跨运行产生 +相同发放顺序。`setMaxGenerating` 限制同时发出的请求,`setMaxActiveCells` 为 active 与 +in-flight Cell 设置常驻硬上限(0 表示无限), +`setMaxPointsPerCell` 和 `setMaxResidentPoints` 分别限制单 Cell 与全部 active Cell 的点数; +超预算提交不会发布,调用方可缩减输出后重交同一 ticket,或调用 `failGeneration` 进入有界 +重试。`getMaxPointsPerCell` / `getMaxResidentPoints` 返回当前配置,`getResidentPointCount` 与 +`getRejectedOutputCount` 提供内存压力遥测,0 表示无限制。全局预算拒绝提交时,调度器会按 +当前 source 距离/朝向优先级确定性地将最远 active Cell 放入 cleanup 队列;也可调用 +`trimToResidentPoints(target)` 主动收缩缓存。被 trim 的 Cell 在清理完成前不会因 source +刷新而复活;其 PointSet 在 `completeCleanup` 确认前仍计入常驻预算,实际释放后原异步提交 +才可安全重交。 +`setFrameTimeBudget` / `beginFrame` 限制一帧内继续发放工作的 CPU 时间。每格拥有由 +world seed、level、x、z 派生的独立 seed、revision 和 PointSet 输出缓存。每次异步生成或 +清理还携带唯一 `getTicket()`;Cell 离开、重新进入调度范围后,旧任务即使 seed 相同也会 +被 `completeGeneration` / `completeCleanup` 拒绝,避免大世界流送中的 ABA 陈旧提交。 +生成失败只按 `setMaxGenerationRetries` 有界重试,耗尽后进入 Failed 状态并从工作队列移除; +修复资产或外部依赖后用 `retryFailedCells()` 显式恢复,避免确定性错误形成 retry storm。 +`getCellOutput` 返回缓存副本,`debugReport` 汇总 pending/generating/active/cleanup/failed。 +需要跨会话或跨 World Partition 回访复用时,`serializeCell(level,x,z)` 输出版本化、属性键 +稳定排序的完整 Cell 缓存;`deserializeCell(definition)` 校验 world seed、level、数据上限和 +完整输入后原子恢复,同时使同 Cell 的旧异步 ticket 失效。实例覆写或图版本应由调用方纳入 +缓存文件名/外部 build key,避免把旧内容恢复到新图定义。 + +调度器也支持多个命名 generation source。Cell 只生成一次,但只要仍在任意 source 的 +cleanup radius 内就不会清理;`radiusScale` 可让任务目标使用比玩家更小的影响范围: + +```nut +runtime.setGenerationSource("player", playerX, playerZ, forwardX, forwardZ, 1.0); +runtime.setGenerationSource("quest-target", questX, questZ, 0, 0, 0.5); +runtime.removeGenerationSource("quest-target"); +``` + +`setFrustumCulling(true, halfAngleDegrees, behindRadius)` 只给视锥内远处 Cell 发放生成请求, +但 `behindRadius` 内的近处 Cell 无论朝向都会生成,避免玩家转身时看到空洞。已经 Active +的 Cell 不会仅因离开视锥立即消失,仍按所有 source 的 cleanup radius 使用迟滞清理。 + +### 发布到 Scene + +当 scene 模块存在时,它通过 `IProcgenSceneSink` capability 接收实例批次;procgen +本身没有 scene include 或链接依赖。`publishInstances` 从 PointSet 的位置、yaw、scale +和稳定 seed 创建场景节点,并用指定 string attribute 选择资产名: + +```nut +procgen.publishInstances("forest/manual", trees, "asset", "oak"); +local count = procgen.getPublishedInstanceCount("forest/manual"); +local reused = procgen.getPublishedReusedCount("forest/manual"); +procgen.removeInstances("forest/manual"); +``` + +Scene 为每个批次建立 `__pcg/` Host,节点带 `pcg`、`pcg.instance` 和 +`pcg.asset:` tag。同一批次再次发布会按稳定节点 id reconcile,生成失败不会先 +破坏其它批次。默认实例 id 使用 point seed 的局部重复序号,因此插入其它 seed 的点不会 +让整批对象失去池化身份;string metadata `instanceId` 可提供项目级稳定 id。发布前会拒绝 +重复 id,失败时 Scene Sink 中的旧批次保持不变。 +`getPublishedCreatedCount`、`getPublishedReusedCount` 和 `getPublishedRemovedCount` +返回最近一次成功 apply/remove 的实例池变化,可用于流送 profiler 和复用率告警。成员增删 +导致 SceneNode arena 重排时,Scene 仍按稳定实例 id 恢复已挂接的渲染、物理和脚本对象, +避免把结构变化误当成整批资源重建。运行时 Cell 使用 +`publishCellInstances(prefix, request, points, ...)` +和 `removeCellInstances(prefix, request)`,其批次 id 自动包含 level/x/z。 + +完整组合见 `examples/pcg-biome`:大 Cell 放置乔木,小 Cell 放置草和岩石,Spline +道路作为排除域,移动生成源会触发带迟滞的生成与清理。 + +### Point Graph 与子图 + +需要数据驱动资产或可视化编辑器时,用 `newPointGraph()` 构建与 UE PCG Graph 类似的 +惰性 DAG;普通项目仍可继续直接组合前述纯函数。节点以稳定字符串 id 标识,连接到 +input slot 0/1,执行时自动拒绝缺失输入和环: + +```nut +local graph = procgen.newPointGraph(); +graph.addNode("sample", "spatial.sample"); +graph.setNodeSpatial("sample", forestVolume); +graph.setNodeFloat("sample", "spacing", 12.0); +graph.setNodeFloat("sample", "jitter", 0.7); +graph.setNodeInt("sample", "seed", 42); + +graph.addNode("road-exclusion", "spatial.filter"); +graph.setNodeSpatial("road-exclusion", roadSpatial); +graph.setNodeInt("road-exclusion", "invert", 1); +graph.connect("sample", "road-exclusion"); + +graph.addNode("prune", "self.prune"); +graph.setNodeFloat("prune", "radius", 10.0); +graph.connect("road-exclusion", "prune"); +if (!graph.validate()) throw graph.getError(); +local trees = graph.execute("prune"); +``` + +支持的 operation 可用 `getOperationCount/getOperationId` 枚举,包括: +`input`、`spatial.sample/filter/project`、`merge`、`copy.points`、`transform`、 +`density.remap`、`attribute.math.float`、 +`filter.float/string`、`attribute.set.float/string`、`density.cull`、 +`self.prune`、`jitter`、`biome.generate`、`grammar.generate`、`branch` 和 `subgraph`。 + +`biome.generate` 通过 `setNodeSpatial` 与 `setNodeBiomeRules` 绑定生成域和 Biome 规则, +反射 `spacing/seed/jitter`;`grammar.generate` 通过 `setNodeShapeGrammar` 绑定 Shape Grammar, +接收控制点输入并反射 `grammar/seed/acceptIncomplete`。setter 会复制规则资产,调用方销毁或 +热重载原对象不会悬空;这些外部槽和 PointSet/SpatialData 一样不写入图定义,加载或 +`instantiate()` 后必须重新绑定。 + +`copy.points` 对每个 target 实例化全部 source 点(稳定的 target-major 顺序),组合位置、 +yaw、scale、density 与独立 seed;可继承 target metadata,冲突时 source metadata 胜出。 +`maxPoints` 在笛卡尔积分配前实施硬上限,防止错误图造成编辑器或流式任务内存爆炸。 +`density.remap` 线性映射 density 范围并可钳制输出;`attribute.math.float` 对 float metadata +执行 add/subtract/multiply/divide/min/max,可写入新属性并为缺失输入提供确定性默认值。 +零输入范围、非法 operation 和除零会作为节点执行错误报告。 + +`execute(outputId)` 只求值该输出的祖先节点;没有配置变化时复用缓存。节点参数、输入或 +空间数据变化时只失效该节点及其下游,未受影响的分支继续复用结果;`getRevision()` 提供 +单调递增的资产/预览缓存版本。序列化会稳定排序参数键,等价图在不同平台产生相同定义字节。 +`setNodeFloat`、`setNodeInt` 和 `setNodeString` 按 operation 反射 schema 校验参数名与类型; +未知参数或类型不匹配会被拒绝,资产反序列化也遵循同一规则并保持事务式失败。 +大型图可用 `setExecutionNodeBudget(maxNodes)` 限制一次调用新求值的节点数;超出预算会保留 +已经完成的上游缓存,下一次提高预算后可继续求值。`requestCancel()` / `resetCancellation()` +为编辑器预览和任务队列提供显式取消边界,`wasCancelled()` 区分预算/取消退出与数据错误。 +`setMaxNodeOutputPoints(maxPoints)` 对每个节点实施统一硬上限;空间采样和 Biome 在分配前按 +bounds/spacing 计算保守上界,其余节点在结果进入缓存前拒绝并释放超限输出。降低预算会清除 +旧缓存,避免大结果通过 cache hit 绕过新限制;`getMaxNodeOutputPoints()` 返回当前上限。 +图资产可用 `exposeParameter(name, nodeId, key)` 将节点参数公开为强类型图参数;运行时实例通过 +`setParameterFloat/Int/String()` 覆写,`clearParameterOverride()` 恢复资产默认值。覆写不会写入 +序列化资产,并且只失效目标节点及下游缓存,适合关卡实例、Biome 和流式 Cell 共享图定义。 +`instantiate()` 从资产定义创建独立运行实例,不复制 PointSet/SpatialData/BiomeRules/ +ShapeGrammar 外部输入、覆写、缓存、 +metric 或取消状态;并行 Cell/后台任务应各自实例化并重新绑定输入,不能共享同一个可变实例。 +`getNodeOutput` 可取得任意已执行节点的中间 PointSet;`getMetric*` 和 +`debugReport` 提供逐节点输出数量、耗时及 cache hit。任意 node/edge/parameter/input +修改都会失效整图缓存,避免返回旧结果。 +调试视图无需复制 PointSet 即可用 `getMetricMinX()`、`getMetricMinY()`、`getMetricMinZ()`、 +`getMetricMaxX()`、`getMetricMaxY()`、`getMetricMaxZ()` 绘制节点 bounds,并用 +`getMetricAverageDensity()` 显示密度热度;这些摘要在首次执行时计算并随节点 cache 保存, +cache hit 不会再次遍历大型点集。 + +### Vulkan / WebGPU Point Compute + +`ProcgenPointGraph.setComputePolicy("auto" | "gpu" | "cpu")` 控制点处理执行器。默认 `auto` +在输入达到 `setComputeMinimumPoints()`(默认 1024)后,将 `transform` 的位置、法线、旋转和 +缩放批量打包到 storage buffer,通过单个 Sequence 完成上传、dispatch 和回读;Vulkan 使用 +GLSL/SPIR-V,WebGPU 使用 WGSL。设备尚未初始化、shader 编译、提交或回读失败时会确定性地 +回退现有 CPU 实现,`getComputeFallbackReason()` 返回原因。 + +`getMetricBackend(index)` 返回该节点实际使用的 `cpu`、`vulkan` 或 `webgpu`,`debugReport()` +也包含 `backend=`。这使编辑器和性能采集能够区分 GPU 命中与 CPU fallback。当前 GPU kernel +覆盖 `transform`;其余节点继续走 CPU,后续可在同一执行层扩展为连续节点融合,无需改变图资产。 + +融合与传输统计由 `getComputeUploadCount()`、`getComputeDispatchCount()`、 +`getComputeReadbackCount()`、`getComputeBufferReuseCount()`、`getComputePeakBufferBytes()` 和 +`getLastFusedTransformCount()` 提供。计数属于图实例持有的 Compute 执行器并跨 `execute()` 累积, +可用执行前后差值验证一个图段是否只发生一次上传、dispatch 和回读。 + +子图节点将首个输入写入嵌套图指定的 input node,并返回指定 output node: + +```nut +local sub = procgen.newPointGraph(); +sub.addNode("in", "input"); +sub.addNode("jitter", "jitter"); +sub.connect("in", "jitter"); +sub.setNodeInt("jitter", "seed", 99); +sub.setNodeFloat("jitter", "x", 2.0); +sub.setNodeFloat("jitter", "z", 2.0); + +graph.addNode("detail-subgraph", "subgraph"); +graph.connect("prune", "detail-subgraph"); +graph.setNodeSubgraph("detail-subgraph", sub, "in", "jitter"); +``` + +### Biome 规则 + +`newBiomeRules()` 对应 UE PCG Biome Core 的数据驱动分布层。每个 layer 有空间域、 +priority 和 density;重叠位置选择 priority 最大的层。全局 exclusion 在层选择前剔除 +候选,资产表按 weight 稳定选择,并可设置随机 yaw 与缩放范围: + +```nut +local biome = procgen.newBiomeRules(); +biome.addLayer("forest", forestVolume, 10, 0.72); +biome.addAsset("forest", "oak", 4.0, 0.8, 1.2, true); +biome.addAsset("forest", "pine", 1.0, 0.9, 1.3, true); + +biome.addLayer("town-park", parkVolume, 20, 0.45); // 覆盖 forest +biome.addAsset("town-park", "maple", 1.0, 0.9, 1.1, true); +biome.addExclusion(roadSpatial); + +local instances = biome.generate(cellDomain, 12.0, cellSeed, 0.65); +``` + +输出点写入 `biome` 和 `asset` string attribute,并设置 density、uniform scale、yaw; +同一 domain/spacing/seed/rules 始终得到相同结果。规则对象复制传入的 SpatialData, +调用方销毁原对象不影响生成。`debugReport` 给出 layer、exclusion、candidate 和 output +数量。`examples/pcg-biome` 分别使用 coarse/detail 两套规则并把结果继续送入 PointGraph。 + +### Shape Grammar 与 Assembly + +`newShapeGrammar()` 沿任意 3D polyline 连续拼装模块。一个 symbol 可以注册多个等长、 +不同 weight 的资产变体;输出点携带 `module`、`asset` 和 `length` 属性,位置位于模块 +中心,yaw 沿当前 Spline 切线: + +```nut +local grammar = procgen.newShapeGrammar(); +grammar.addModule("Wall", "stone-wall-a", 4.0, 3.0); +grammar.addModule("Wall", "stone-wall-b", 4.0, 1.0); +grammar.addModule("Post", "stone-post", 1.0, 1.0); +grammar.addModule("Gate", "gate", 6.0, 1.0); + +local fence = grammar.generate("Post,[Wall,Post]*,Gate", roadSpline, 42, true); +procgen.publishInstances("assembly/fence", fence, "asset", "stone-wall-a"); +``` + +语法支持 `A,B` 顺序、`[A,B]` 分组、`A3` 固定重复、`A*` 尽量填充和 `A+` +至少一次后尽量填充。`acceptIncomplete=false` 时必选模块放不下会失败;设为 true +则只输出完整放得下的模块,绝不产生被截断的实例。`validate` 可在编辑器保存前报告 +未知 symbol、未闭合分组和非法 token。 + +### Graph 资产保存 + +`serializeDefinition()` 返回带版本头的确定性图定义,保存 topology、edge、float/int/ +string 参数以及递归嵌套子图。`deserializeDefinition()` 事务式加载:格式、edge 或环校验 +失败时保留当前图不变。 + +PointSet 和 SpatialData 被视为项目/关卡提供的外部资源槽,不内嵌进图定义;加载后通过 +`setNodePoints` / `setNodeSpatial` 重新绑定。这样同一图资产可以用于不同 Cell、地形或 +关卡输入,也不会把大型运行时点缓存重复写入资产文件。 + +editor 与 procgen 同时启用时提供 `PcgPointGraphDomain`(domain id `procgen.point`)。 +它使用运行时 operation schema 自动创建通用 `GraphNodeRecord` 的 typed pins 和默认 +properties,经现有 `GraphDocument` 编辑、revision 和 persistence 流程后编译成上述 +PointGraph 定义。非法 pin 类型、重复节点、坏 edge 和 cycle 会产生 editor diagnostic; +procgen 被裁剪时 `EditorPcgGraph.cpp` 自动从 editor 模块源列表排除。 +编译器拒绝不支持的 `schemaVersion`、未知 property、类型不匹配、非有限浮点和超出运行时 +整数/浮点范围的值,避免拼写或反序列化错误静默进入运行时资产。 +`PcgPointGraphDomain::migrate()` 提供事务式 schema 升级;当前迁移链把 v0 资产升级到 v1, +按运行时反射补齐新增默认 property、规范化 node pin id,并同步重写 edge 端点,同时保留 +document revision 与稳定 node/edge id。`compile()` 会自动执行迁移并返回 +`editor.pcg.migrated-v0-v1` 信息诊断;未知 operation、重复 legacy pin 或非法 properties +会以稳定 rule id 拒绝,输入资产保持不变,便于批量升级、撤销和版本控制审查。 +`PcgPointGraphDomain::preview()` 在隔离的运行时图上绑定临时 PointSet 并只执行指定 output 的 +祖先节点,不会污染资产缓存或参数覆写。结果携带 document revision、最终点数,以及每个已 +执行节点的耗时、输出点数、cache hit、XYZ 包围盒和平均 density,可直接驱动 graph badge、 +viewport bounds 与性能热区覆盖;无效输入、绑定失败、执行失败和取消使用稳定 diagnostic。 +预览默认对单节点输出实施 100,000 点硬上限;调用方可通过 `pointBudget` 调低或显式传 0 +关闭限制。超限结果在进入预览 metric/cache 前释放并返回稳定 execution-failed diagnostic。 +`GraphDocument::setParameters()` 接受 `{ publicName: { node: nodeId, key: parameterKey } }` +黑板对象;编译器校验节点、反射参数类型和重复目标,并把绑定写入 PointGraph 资产,运行时 +实例随后可通过 `setParameterFloat/Int/String()` 覆写。 + ### 事务式 hot reload 命名 `ProcgenContext` 是一次完整重建的 staging 区。`publish` 会复制输出, @@ -590,6 +928,29 @@ local castle = castleResult.value; - 在每帧 update 生成大地图或纹理。 - 在每帧重新生成树木网格;应缓存 `Mesh`,仅在 seed 或参数变化时重建。 +## L-system 文法生成 + +通用随机括号 L-system 引擎(`procgen.newLSystem()`)。给定 axiom 与产生式(可带权重随机),迭代若干次后用 3D 海龟解释:绘制 `/` 折返、`[ ]` 入/弹栈产生分支,枝条粗细随深度衰减。固定 seed 结果完全可复现。除 `mesh.lsystem` 网格配方外,`trace()` 可把枝段作为样条控制点输出(道路、二维布局)。 + +```squirrel +local ls = procgen.newLSystem(); +ls.setAxiom("F"); +ls.addRule('F', "F[+F]F[-F]F"); // 确定性产生式 +// ls.addRules('A', ["F[+A]A", "FA"], [2.0, 1.0]); // 加权随机产生式 +ls.setIterations(4); ls.setAngle(26.0); ls.setSeed(42); +local road = procgen.newPointSet(); ls.trace(road); // 枝段 → 控制点 +``` + +`mesh.lsystem` 配方内置 `tree` / `fern` / `plant` / `weed` 预置,输出锥形枝干与叶片卡。 + +## 蓝噪声撒点 + +`procgen.poissonDisk(width, depth, radius, seed, maxPoints)` 在 XZ 平面做 Bridson 蓝色噪声撒点(任意两点间距 ≥ radius,确定性),适合均匀散布草丛、石头等。 + +```squirrel +local scatter = procgen.poissonDisk(100, 100, 2.5, 99, 500); // 最多 500 点 +``` + ## API 快查 下列方法名来自当前 Squirrel 绑定;同一模块创建的辅助对象(例如 `World`、`Body`、`Source`)的方法也列在这里。 @@ -601,7 +962,9 @@ local castle = castleResult.value; - `getTarget()`、`getTextureRecipeCount()`、`getTextureRecipeId()`、`getWidth()`、`gridToJson()`、`has()`、`hasAlgorithm()`、`hasMeshRecipe()`、`hasTextureRecipe()`、`applyMeshRecipeDefaults()` - `getDensity()`、`getError()`、`getFloatAttribute()`、`getNormalX()`、`getNormalY()`、`getNormalZ()`、`getOutput()`、`getOutputCount()`、`getOutputName()`、`getPointSeed()`、`getScaleX()`、`getScaleY()`、`getScaleZ()`、`getStringAttribute()`、`getSystemDebugReport()`、`getSystemOutput()`、`getSystemOutputCount()`、`getSystemOutputName()`、`getSystemRevision()`、`getSystemSeed()`、`getTraceCount()`、`getTraceInputCount()`、`getTraceMilliseconds()`、`getTraceName()`、`getTraceOutputCount()`、`getTriangleGroup()`、`getX()`、`getY()`、`getYaw()`、`getZ()`、`hasFailed()`、`hasFloatAttribute()`、`hasOutput()`、`hasStringAttribute()`、`hasSystem()`、`isActive()`、`jitterPoints()`、`newGrid()`、`newOutput()`、`newParams()`、`newPointSet()`、`publish()`、`randomSeed()`、`removeSystem()`、`resize()`、`sampleGrid()`、`seedFor()`、`selfPrune()`、`setCell()`、`setDensity()`、`setDetail()`、`setFloat()`、`setFloatAttribute()`、`setInt()` - `setLayer()`、`setMeta()`、`setNormal()`、`setPalette()`、`setPaletteGid()`、`setPath()`、`setPointSeed()`、`setPosition()`、`setScale()`、`setSeed()`、`setSize()`、`setString()`、`setStringAttribute()`、`setYaw()`、`trace()`、`setActiveGroup()` +- L-system 引擎(`ProcgenLSystem`):`addRule()`、`addRules()`、`clearRules()`、`derive()`、`getIterations()`、`getSeed()`、`setAngle()`、`setAxiom()`、`setBranchRadius()`、`setBranchRadiusFalloff()`、`setInitialHeading()`、`setIterations()`、`setLeafSize()`、`setLeafSymbols()`、`setStep()`、`setTropism()`;蓝噪声撒点:`poissonDisk()`。这些创建与转换入口返回统一 Result,其 `value` 是带所有权的代理。 - `setTarget()` +- UE PCG 扩展:`clearCache()`、`clearGenerationSources()`、`clearParameterOverride()`、`disconnect()`、`exposeParameter()`、`getActiveCellCount()`、`getAssetCount()`、`getAssetName()`、`getCacheHitCount()`、`getCellRevision()`、`getComputeBufferReuseCount()`、`getComputeDispatchCount()`、`getComputeFallbackReason()`、`getComputeMinimumPoints()`、`getComputePeakBufferBytes()`、`getComputePolicy()`、`getComputeReadbackCount()`、`getComputeUploadCount()`、`getDirectionWeight()`、`getExclusionCount()`、`getExecutionCount()`、`getExecutionNodeBudget()`、`getFailedCellCount()`、`getFrameTimeBudget()`、`getFrustumBehindRadius()`、`getFrustumHalfAngle()`、`getGeneratingCount()`、`getGenerationSourceCount()`、`getGenerationSourceId()`、`getInputNode()`、`getLastFusedTransformCount()`、`getLayerCount()`、`getLayerDensity()`、`getLayerName()`、`getLayerPriority()`、`getLevelCellSize()`、`getLevelCleanupRadius()`、`getLevelCount()`、`getLevelGenerationRadius()`、`getMaxActiveCells()`、`getMaxGenerating()`、`getMaxGenerationRetries()`、`getMaxY()`、`getMetricBackend()`、`getMetricCount()`、`getMetricMilliseconds()`、`getMetricNodeId()`、`getMetricOutputCount()`、`getMinY()`、`getModuleCount()`、`getModuleSymbol()`、`getNodeCount()`、`getNodeId()`、`getNodeOperation()`、`getOperationInputCount()`、`getOperationParamCount()`、`getOperationParamDefault()`、`getOperationParamKey()`、`getOperationParamKind()`、`getParameterCount()`、`getParameterFloat()`、`getParameterInt()`、`getParameterKind()`、`getParameterName()`、`getParameterString()`、`getPendingCleanupCount()`、`getPendingGenerateCount()`、`getRevision()`、`getTicket()`、`getVariantAsset()`、`getVariantCount()`、`getVariantLength()`、`hasCell()`、`hasLayer()`、`hasModule()`、`hasNode()`、`hasParameterOverride()`、`intersectSpatial()`、`isFrustumCullingEnabled()`、`isMetricCacheHit()`、`pointData()`、`refreshGenerationSources()`、`removeLayer()`、`removeModule()`、`removeNode()`、`requestCancel()`、`resetCancellation()`、`retryFailedCells()`、`setComputeMinimumPoints()`、`setComputePolicy()`、`setExecutionNodeBudget()`、`setMaxActiveCells()`、`setMaxGenerationRetries()`、`setNodeString()`、`setParameterFloat()`、`setParameterInt()`、`setParameterString()`、`wasCancelled()`。 ## 使用要点 diff --git a/examples/README.md b/examples/README.md index 829bfa9c..569c4378 100644 --- a/examples/README.md +++ b/examples/README.md @@ -37,6 +37,7 @@ make run/-debug GAME=examples/ | [building](building/README.md) | 建筑放置:地形约束、道路邻接、鬼影预览、旋转拆除 | | [building-tilemap](building-tilemap/README.md) | 在等距 / 六角 tilemap 上放置建筑(2D 精灵穿插) | | [iso-grid-walk](iso-grid-walk/README.md) | 独立 2.5D PNG 经可插拔 pipeline 生成 TileSet,方格移动与 A* | +| [dynamic-water-grid](dynamic-water-grid/README.md) | 双网格岸线、逐格守恒水量、等距连续水面 Shader 与素材替换契约 | | [metroidvania](metroidvania/README.md) | 物理驱动的横版动作游戏:连击、蹬墙跳、空中冲刺、Boss | | [hex-levels](hex-levels/README.md) | 六边形引擎功能测试关卡:寻路 / FOV / 光照 / 掉落 / WFC(31 关) | | [i18n](i18n/README.md) | 本地化:翻译表、占位符、复数规则、热重载 | @@ -59,6 +60,7 @@ make run/-debug GAME=examples/ |---|---| | [procgen](procgen/README.md) | 六种地图算法(BSP / Cellular / Drunkard / Maze / 地形 / WFC)+ 纹理配方 | | [procgen-script-pipeline](procgen-script-pipeline/README.md) | 纯脚本 PointSet 组合、确定性 seed、事务式 hot reload | +| [pcg-biome](pcg-biome/README.md) | UE PCG 风格空间数据、多层运行时 Cell、时间预算与 Scene 实例批次 | | [roguelike-generator](roguelike-generator/README.md) | 种子驱动的房间走廊地牢:autotile、装饰、2D ↔ 2.5D | | [tree-generator](tree-generator/README.md) | 确定性树木:Weber-Penn / 空间殖民两种骨架算法 | | [bush-generator](bush-generator/README.md) | 低多边形灌木配方(mesh.bush)与参数实时调节 | diff --git a/examples/ai-game/main.nut b/examples/ai-game/main.nut index 9df17cc9..684775ae 100644 --- a/examples/ai-game/main.nut +++ b/examples/ai-game/main.nut @@ -15,8 +15,7 @@ // 一键复现:python examples/ai-game/agent_demo.py 7529 // ============================================================================ -game <- persist("game", function() { - return { +persist game = { tick = 0 time = 0.0 hits = 0 @@ -24,8 +23,7 @@ game <- persist("game", function() { eneTimer = 0.0 player = { hp = 100.0, maxHp = 100.0, attack = 14.0 } enemy = { hp = 80.0, maxHp = 80.0, attack = 9.0 } - }; -}); + } // --- Agent 可调用的脚本入口(MCP eve_run_script / eve_eval 均可触达) --- game.setEnemyHp <- function(v) { game.enemy.hp = v.tofloat(); }; @@ -81,10 +79,10 @@ eve_update = function(dt) { local hpText = format("玩家 HP %.0f/%.0f 敌人 HP %.0f/%.0f", game.player.hp, game.player.maxHp, game.enemy.hp, game.enemy.maxHp); ui.setText("status", state + "\n" + hpText); - ui.setText("hint", - game.player.hp <= 0.0 - ? "GAME OVER —— Agent 可用 eve_run_script: game.reset(); 重开" - : "MCP: eve_eval / eve_run_script / eve_screenshot / eve_snapshot_*"); + local hint = game.player.hp <= 0.0 + ? "GAME OVER —— Agent 可用 eve_run_script: game.reset(); 重开" + : "MCP: eve_eval / eve_run_script / eve_screenshot / eve_snapshot_*"; + ui.setText("hint", hint); }; eve_render = function() { diff --git a/examples/ai-stage/main.nut b/examples/ai-stage/main.nut index 6a6f9ffe..a1d2973a 100644 --- a/examples/ai-stage/main.nut +++ b/examples/ai-stage/main.nut @@ -24,8 +24,8 @@ if ("sceneDirectorScript" in eve && eve.sceneDirectorScript != "") { } } -if (!("scene_director" in getroottable())) scene_director <- null; -if (!("sd" in getroottable())) sd <- null; +persist scene_director = null +persist sd = null if (scene_director != null) sd = scene_director; // Minimal default stage so the window is never empty before an agent builds. diff --git a/examples/atmospheric-fog/main.nut b/examples/atmospheric-fog/main.nut index 67e7e2b2..8e52a3fb 100644 --- a/examples/atmospheric-fog/main.nut +++ b/examples/atmospheric-fog/main.nut @@ -1,12 +1,12 @@ // Atmospheric froxel fog: a complete scene-depth -> volume-atlas -> composite example. // 1/2/3 select thin, medium and dense fog. Space toggles the effect. -if (!("fogDemoCamera" in getroottable())) fogDemoCamera <- null; -if (!("fogDemoVolume" in getroottable())) fogDemoVolume <- null; -if (!("fogDemoObjects" in getroottable())) fogDemoObjects <- []; -if (!("fogDemoEnabled" in getroottable())) fogDemoEnabled <- true; -if (!("fogDemoPreset" in getroottable())) fogDemoPreset <- 2; -if (!("fogDemoSpaceDown" in getroottable())) fogDemoSpaceDown <- false; +persist fogDemoCamera = null +persist fogDemoVolume = null +persist fogDemoObjects = [] +persist fogDemoEnabled = true +persist fogDemoPreset = 2 +persist fogDemoSpaceDown = false function fogDemoObject(mesh, x, y, z, sx, sy, sz, r, g, b) { local object = eve.Renderable3D(); diff --git a/examples/basic/main.nut b/examples/basic/main.nut index 5d9686d6..86932d27 100644 --- a/examples/basic/main.nut +++ b/examples/basic/main.nut @@ -4,20 +4,13 @@ // Soft hot-reload: put one-time setup in eve_init; guard mutable state so // re-dofile does not wipe C++ entity refs. Optional eve_reload / eve_asset_reload. -if (!("fx" in getroottable())) - fx <- null; -if (!("tileLayer" in getroottable())) - tileLayer <- null; -if (!("world" in getroottable())) - world <- null; -if (!("ground" in getroottable())) - ground <- null; -if (!("boxBody" in getroottable())) - boxBody <- null; -if (!("boxW" in getroottable())) - boxW <- 48.0; -if (!("boxH" in getroottable())) - boxH <- 48.0; +persist fx = null +persist tileLayer = null +persist world = null +persist ground = null +persist boxBody = null +persist boxW = 48.0 +persist boxH = 48.0 eve_init = function() { gfx.setBackgroundColor(0.08, 0.09, 0.14, 1.0); diff --git a/examples/building-3d/main.nut b/examples/building-3d/main.nut index 5e61a899..a93ff80b 100644 --- a/examples/building-3d/main.nut +++ b/examples/building-3d/main.nut @@ -10,17 +10,17 @@ // 运行: make run/-debug GAME=examples/building-3d // ============================================================================ -if (!("bld" in getroottable())) bld <- null; -if (!("fx" in getroottable())) fx <- null; -if (!("world" in getroottable())) world <- null; -if (!("session" in getroottable())) session <- null; -if (!("cam" in getroottable())) cam <- null; -if (!("ground" in getroottable())) ground <- null; -if (!("palette" in getroottable())) palette <- []; -if (!("paletteIndex" in getroottable())) paletteIndex <- 0; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("prevMouse" in getroottable())) prevMouse <- { left = false, right = false }; -if (!("uiBuilt" in getroottable())) uiBuilt <- false; +persist bld = null +persist fx = null +persist world = null +persist session = null +persist cam = null +persist ground = null +persist palette = [] +persist paletteIndex = 0 +persist prevKeys = {} +persist prevMouse = { left = false, right = false } +persist uiBuilt = false const GRID_W = 16; const GRID_H = 16; diff --git a/examples/building-tilemap/main.nut b/examples/building-tilemap/main.nut index 44f80ef4..5a9c0462 100644 --- a/examples/building-tilemap/main.nut +++ b/examples/building-tilemap/main.nut @@ -11,18 +11,18 @@ // 运行: make run/-debug GAME=examples/building-tilemap // ============================================================================ -if (!("bld" in getroottable())) bld <- null; -if (!("mapMod" in getroottable())) mapMod <- null; -if (!("layer" in getroottable())) layer <- null; -if (!("world" in getroottable())) world <- null; -if (!("session" in getroottable())) session <- null; -if (!("fx" in getroottable())) fx <- null; -if (!("palette" in getroottable())) palette <- []; -if (!("paletteIndex" in getroottable())) paletteIndex <- 0; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("prevMouse" in getroottable())) prevMouse <- { left = false, right = false }; -if (!("uiBuilt" in getroottable())) uiBuilt <- false; -if (!("layout" in getroottable())) layout <- "isometric"; +persist bld = null +persist mapMod = null +persist layer = null +persist world = null +persist session = null +persist fx = null +persist palette = [] +persist paletteIndex = 0 +persist prevKeys = {} +persist prevMouse = { left = false, right = false } +persist uiBuilt = false +persist layout = "isometric" const MAP_W = 16; const MAP_H = 12; diff --git a/examples/building/main.nut b/examples/building/main.nut index 150fbb00..0e0ac4d4 100644 --- a/examples/building/main.nut +++ b/examples/building/main.nut @@ -12,22 +12,22 @@ // 或 make building // ============================================================================ -if (!("bld" in getroottable())) bld <- null; -if (!("world" in getroottable())) world <- null; -if (!("ghost" in getroottable())) ghost <- null; -if (!("palette" in getroottable())) palette <- []; -if (!("paletteIndex" in getroottable())) paletteIndex <- 0; -if (!("gold" in getroottable())) gold <- 0; -if (!("wood" in getroottable())) wood <- 0; -if (!("logLines" in getroottable())) logLines <- []; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("prevMouse" in getroottable())) prevMouse <- { left = false, right = false }; -if (!("uiBuilt" in getroottable())) uiBuilt <- false; -if (!("mapOriginX" in getroottable())) mapOriginX <- 24.0; -if (!("mapOriginY" in getroottable())) mapOriginY <- 88.0; -if (!("cellPx" in getroottable())) cellPx <- 28.0; -if (!("gridW" in getroottable())) gridW <- 24; -if (!("gridH" in getroottable())) gridH <- 18; +persist bld = null +persist world = null +persist ghost = null +persist palette = [] +persist paletteIndex = 0 +persist gold = 0 +persist wood = 0 +persist logLines = [] +persist prevKeys = {} +persist prevMouse = { left = false, right = false } +persist uiBuilt = false +persist mapOriginX = 24.0 +persist mapOriginY = 88.0 +persist cellPx = 28.0 +persist gridW = 24 +persist gridH = 18 // 地形语义:1=陆地 2=水域 TERRAIN_LAND <- 1; diff --git a/examples/bush-fog-volumes/main.nut b/examples/bush-fog-volumes/main.nut index 6cdfab12..ce5922dd 100644 --- a/examples/bush-fog-volumes/main.nut +++ b/examples/bush-fog-volumes/main.nut @@ -1,16 +1,16 @@ // Procedural bushes crossing global height fog and two analytic local fog volumes. // 1/2/3/4 select fixed camera checks; V cycles views; F toggles all fog. -if (!("bfCamera" in getroottable())) bfCamera <- null; -if (!("bfFog" in getroottable())) bfFog <- null; -if (!("bfObjects" in getroottable())) bfObjects <- []; -if (!("bfVolumes" in getroottable())) bfVolumes <- []; -if (!("bfView" in getroottable())) bfView <- 0; -if (!("bfFogEnabled" in getroottable())) bfFogEnabled <- true; -if (!("bfTexture" in getroottable())) bfTexture <- null; -if (!("bfElapsed" in getroottable())) bfElapsed <- 0.0; -if (!("bfCapturePending" in getroottable())) bfCapturePending <- false; -if (!("bfCaptureName" in getroottable())) bfCaptureName <- "bush-fog-volumes.png"; +persist bfCamera = null +persist bfFog = null +persist bfObjects = [] +persist bfVolumes = [] +persist bfView = 0 +persist bfFogEnabled = true +persist bfTexture = null +persist bfElapsed = 0.0 +persist bfCapturePending = false +persist bfCaptureName = "bush-fog-volumes.png" bfViews <- [ { ex = 10.5, ey = 5.6, ez = 13.5, tx = 0.0, ty = 1.0, tz = -3.0 }, diff --git a/examples/bush-generator/main.nut b/examples/bush-generator/main.nut index 200e1dd6..6e530fe1 100644 --- a/examples/bush-generator/main.nut +++ b/examples/bush-generator/main.nut @@ -3,15 +3,15 @@ // The default texture is derived from Kenney's CC0 mini-forest colormap // (assets/bush_atlas.png); press T to reload it from disk at any time. -if (!("bushSeed" in getroottable())) bushSeed <- 20260815; -if (!("bushStyle" in getroottable())) bushStyle <- "mound"; -if (!("bushLeafMode" in getroottable())) bushLeafMode <- "mixed"; -if (!("bushDensity" in getroottable())) bushDensity <- 0.62; -if (!("bushMesh" in getroottable())) bushMesh <- null; -if (!("bushObject" in getroottable())) bushObject <- null; -if (!("bushTexture" in getroottable())) bushTexture <- null; -if (!("bushCamera" in getroottable())) bushCamera <- null; -if (!("bushYaw" in getroottable())) bushYaw <- 0.0; +persist bushSeed = 20260815 +persist bushStyle = "mound" +persist bushLeafMode = "mixed" +persist bushDensity = 0.62 +persist bushMesh = null +persist bushObject = null +persist bushTexture = null +persist bushCamera = null +persist bushYaw = 0.0 function pressed(k) { return key_just_pressed(k); } diff --git a/examples/camera-controllers/main.nut b/examples/camera-controllers/main.nut index 87ecc451..b7daa847 100644 --- a/examples/camera-controllers/main.nut +++ b/examples/camera-controllers/main.nut @@ -6,15 +6,18 @@ // - cinematic 过场(一组命名视角之间平滑自动切换) // 运行:make run/win32-debug GAME=examples/camera-controllers -if (!("players" in getroottable())) players <- []; -if (!("markers" in getroottable())) markers <- []; -if (!("ctrl" in getroottable())) ctrl <- null; -if (!("cam" in getroottable())) cam <- null; -if (!("mode" in getroottable())) mode <- "follow"; -if (!("spin" in getroottable())) spin <- true; -if (!("uiReady" in getroottable())) uiReady <- false; -if (!("animT" in getroottable())) animT <- 0.0; -if (!("statusWas" in getroottable())) statusWas <- ""; +persist players = [] +persist markers = [] +persist ctrl = null +persist cam = null +persist mode = "follow" +persist spin = true +persist uiReady = false +persist animT = 0.0 +persist statusWas = "" +persist spinWas = false +persist fpYaw = 0.0 +persist fpPitch = 0.0 local MODES = ["follow", "orbit", "topdown", "firstperson", "cinematic"]; @@ -178,8 +181,8 @@ eve_update = function(dt) { // 空格切换自动盘旋(orbit) local space = keyboard.isDown("Space"); - local spinPressed = space && !("spinWas" in getroottable() ? spinWas : false); - spinWas <- space; + local spinPressed = space && !spinWas; + spinWas = space; if (spinPressed) { spin = !spin; ctrl.setOrbitSpeed(spin ? 20.0 : 0.0); @@ -187,8 +190,6 @@ eve_update = function(dt) { // firstperson:方向键调整朝向 if (mode == "firstperson") { - if (!("fpYaw" in getroottable())) fpYaw <- 0.0; - if (!("fpPitch" in getroottable())) fpPitch <- 0.0; if (keyboard.isDown("left")) fpYaw = fpYaw - 90.0 * dt; if (keyboard.isDown("right")) fpYaw = fpYaw + 90.0 * dt; if (keyboard.isDown("up")) fpPitch = fpPitch + 90.0 * dt; diff --git a/examples/cardgame/main.nut b/examples/cardgame/main.nut index 946776f6..3c1b5719 100644 --- a/examples/cardgame/main.nut +++ b/examples/cardgame/main.nut @@ -13,16 +13,16 @@ // 运行: make run/-debug GAME=examples/cardgame // ============================================================================ -if (!("card" in getroottable())) card <- null; -if (!("playerCfg" in getroottable())) playerCfg <- null; -if (!("enemyCfg" in getroottable())) enemyCfg <- null; -if (!("mana" in getroottable())) mana <- 10; -if (!("played" in getroottable())) played <- 0; -if (!("discarded" in getroottable())) discarded <- 0; -if (!("deckRemaining" in getroottable())) deckRemaining <- 0; -if (!("selected" in getroottable())) selected <- null; -if (!("logLines" in getroottable())) logLines <- []; -if (!("uiBuilt" in getroottable())) uiBuilt <- false; +persist card = null +persist playerCfg = null +persist enemyCfg = null +persist mana = 10 +persist played = 0 +persist discarded = 0 +persist deckRemaining = 0 +persist selected = null +persist logLines = [] +persist uiBuilt = false local defIds = ["flame.element", "frost.guard", "stone.golem", "jungle.drake", "shadow.assassin", "iron.knight", "fireball", "healing.light", "time.warp"]; diff --git a/examples/castle-generator/main.nut b/examples/castle-generator/main.nut index 9b983d91..825c8644 100644 --- a/examples/castle-generator/main.nut +++ b/examples/castle-generator/main.nut @@ -1,14 +1,14 @@ // Castle Forge — interactive mesh.castle showcase. // R seed, 1/2 wall rings, 3/4 keep floors, D detail level. -if (!("castleSeed" in getroottable())) castleSeed <- 20260826; -if (!("castleParts" in getroottable())) castleParts <- []; -if (!("castleCamera" in getroottable())) castleCamera <- null; -if (!("rings" in getroottable())) rings <- 2; -if (!("floors" in getroottable())) floors <- 4; -if (!("detail" in getroottable())) detail <- 2; -if (!("castleYaw" in getroottable())) castleYaw <- 0.0; -if (!("castlePrevKeys" in getroottable())) castlePrevKeys <- {}; +persist castleSeed = 20260826 +persist castleParts = [] +persist castleCamera = null +persist rings = 2 +persist floors = 4 +persist detail = 2 +persist castleYaw = 0.0 +persist castlePrevKeys = {} function castlePressed(k) { local down = keyboard.isDown(k); diff --git a/examples/commandery-rts/main.nut b/examples/commandery-rts/main.nut index 096b49e8..6b2a478f 100644 --- a/examples/commandery-rts/main.nut +++ b/examples/commandery-rts/main.nut @@ -1,11 +1,11 @@ // Commandery RTS: a playable RTS + general/administration composition demo. // Domain meaning lives in this script. Engine modules only store/query generic facts. -if (!("game" in getroottable())) game <- null; -if (!("previousMouse" in getroottable())) previousMouse <- { left=false, right=false }; -if (!("previousKeys" in getroottable())) previousKeys <- {}; -if (!("panelReady" in getroottable())) panelReady <- false; -if (!("selectionDrag" in getroottable())) selectionDrag <- { active=false sx=0.0 sy=0.0 x=0.0 y=0.0 }; +persist game = null +persist previousMouse = { left=false, right=false } +persist previousKeys = {} +persist panelReady = false +persist selectionDrag = { active=false sx=0.0 sy=0.0 x=0.0 y=0.0 } attributes <- eve.Attributes(); social <- eve.Social(); diff --git a/examples/composable-editor/gameplay_components.nut b/examples/composable-editor/gameplay_components.nut index 11a34c17..214c6352 100644 --- a/examples/composable-editor/gameplay_components.nut +++ b/examples/composable-editor/gameplay_components.nut @@ -1,5 +1,34 @@ // Project-owned adapters combine generic Schema/Definitions with Card, Crowd, // terrain and ECS. None of these policies or UI choices live in C++. +// +// Annotation-style schema: class-level `` carries the metadata, +// member-level `` carries the per-field constraints, and each member's +// default value infers its type and JSON default. The schema classes live at +// file scope: Squirrel only supports class-level `` attributes on +// top-level classes. +class CardDefinition { + + id = "" + + name = "" + + kind = "creature" + + cost = 0 + + attack = 0 + + health = 0 +} +class RtsUnit { + + id = "" + + speed = 0.0 + + radius = 0.0 +} + class GameplayEditorComponents { schemas = null definitions = null @@ -15,23 +44,8 @@ class GameplayEditorComponents { crowdSim = eve.Crowd(); units = []; - schemas.registerJson(@"{ - ""id"":""card.definition"",""version"":1,""title"":""Card Definition"", - ""additionalProperties"":false,""fields"":[ - {""name"":""id"",""type"":""string"",""required"":true}, - {""name"":""name"",""type"":""string"",""required"":true}, - {""name"":""kind"",""type"":""string"",""enum"":[""creature"",""spell""]}, - {""name"":""cost"",""type"":""integer"",""minimum"":0,""maximum"":20}, - {""name"":""attack"",""type"":""integer"",""minimum"":0,""maximum"":99}, - {""name"":""health"",""type"":""integer"",""minimum"":0,""maximum"":99} - ]}"); - schemas.registerJson(@"{ - ""id"":""rts.unit"",""version"":1,""title"":""RTS Unit"", - ""additionalProperties"":false,""fields"":[ - {""name"":""id"",""type"":""string"",""required"":true}, - {""name"":""speed"",""type"":""number"",""minimum"":0,""maximum"":20}, - {""name"":""radius"",""type"":""number"",""minimum"":0.1,""maximum"":5} - ]}"); + if (!schemas.registerFromClass(CardDefinition) || + !schemas.registerFromClass(RtsUnit)) return; local cardJson = @"{""id"":""card.scout"",""name"":""Terrain Scout"",""kind"":""creature"",""cost"":2,""attack"":3,""health"":2}"; if (schemas.validateJson("card.definition", cardJson).ok) { diff --git a/examples/composable-editor/main.nut b/examples/composable-editor/main.nut index a96db1df..8f1810cf 100644 --- a/examples/composable-editor/main.nut +++ b/examples/composable-editor/main.nut @@ -46,8 +46,7 @@ class WorldEditorVM { status = "Workspace composed from project descriptors" } -state <- persist("composableEditor", function() { - return { +persist state = { workspace = null session = null vm = null @@ -88,8 +87,7 @@ state <- persist("composableEditor", function() { lastTargetRevision = 0 frameCount = 0 screenshotSaved = false - }; -}); +} materials <- { grass = { r=0.24, g=0.46, b=0.22, metallic=0.0, roughness=0.92 }, diff --git a/examples/crowd/main.nut b/examples/crowd/main.nut index 7329616e..74d1c639 100644 --- a/examples/crowd/main.nut +++ b/examples/crowd/main.nut @@ -2,18 +2,12 @@ // 1 = flow, 2 = seek, 3 = boids, left mouse = new target, space = pause. // Soft hot-reload guards: keep C++-side crowd state across script reloads. -if (!("crowd" in getroottable())) - crowd <- null; -if (!("mode" in getroottable())) - mode <- "flow"; -if (!("goalX" in getroottable())) - goalX <- 0.0; -if (!("goalY" in getroottable())) - goalY <- 0.0; -if (!("paused" in getroottable())) - paused <- false; -if (!("mouseWasDown" in getroottable())) - mouseWasDown <- false; +persist crowd = null +persist mode = "flow" +persist goalX = 0.0 +persist goalY = 0.0 +persist paused = false +persist mouseWasDown = false const AGENT_COUNT = 2000; const GRID_W = 80; // 80 * 12 = 960 px diff --git a/examples/daynight/main.nut b/examples/daynight/main.nut index d5bac9b5..f2322ff9 100644 --- a/examples/daynight/main.nut +++ b/examples/daynight/main.nut @@ -11,13 +11,13 @@ // Up/Down : speed up / slow down the clock // Left : reset to solar noon -if (!("uiReady" in getroottable())) uiReady <- false; -if (!("camAngle" in getroottable())) camAngle <- 0.0; -if (!("autoOrbit" in getroottable())) autoOrbit <- true; -if (!("props" in getroottable())) props <- []; -if (!("clouds" in getroottable())) clouds <- null; -if (!("cloudPreviewTime" in getroottable())) cloudPreviewTime <- 0.0; -if (!("cloudPreviewSaved" in getroottable())) cloudPreviewSaved <- false; +persist uiReady = false +persist camAngle = 0.0 +persist autoOrbit = true +persist props = [] +persist clouds = null +persist cloudPreviewTime = 0.0 +persist cloudPreviewSaved = false // Firefly spawn ring around the campfire. fireflies <- []; diff --git a/examples/devlab/main.nut b/examples/devlab/main.nut index e7e36ee0..7122e4e3 100644 --- a/examples/devlab/main.nut +++ b/examples/devlab/main.nut @@ -15,9 +15,8 @@ // 小球也会按新公式运动。修改 assets 或 JSON 资源则走 eve_asset_reload。 // ============================================================================ -// 热重载持久化:persist() 保留已创建的 C++ 对象与累计状态。 -lab <- persist("lab", function() { - return { +// 热重载持久化:EveScript persist 声明保留已创建的 C++ 对象与累计状态。 +persist lab = { count = 0 reloads = 0 x = 120.0 @@ -25,8 +24,7 @@ lab <- persist("lab", function() { vx = 96.0 vy = 60.0 lastLog = "" - }; -}); + } // --- 热重载试验台:改这个函数,保存,看小球运动立刻变化 --- function bounceDir(v, maxV) { diff --git a/examples/dialogue/main.nut b/examples/dialogue/main.nut index ce7aa564..cc1f624d 100644 --- a/examples/dialogue/main.nut +++ b/examples/dialogue/main.nut @@ -12,18 +12,18 @@ // 运行:make run/linux-debug GAME=examples/dialogue // ============================================================================ -if (!("dlg" in getroottable())) dlg <- null; -if (!("i18n" in getroottable())) i18n <- eve.I18n(); -if (!("aliceAv" in getroottable())) aliceAv <- null; -if (!("bobAv" in getroottable())) bobAv <- null; -if (!("vnGen" in getroottable())) vnGen <- null; -if (!("vnDone" in getroottable())) vnDone <- false; -if (!("waitingResume" in getroottable())) waitingResume <- false; +persist dlg = null +persist i18n = eve.I18n() +persist aliceAv = null +persist bobAv = null +persist vnGen = null +persist vnDone = false +persist waitingResume = false // mouse / touch 边沿检测仍用 prevKeys(edgePressed)。 -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("uiReady" in getroottable())) uiReady <- false; -if (!("curSceneName" in getroottable())) curSceneName <- "town"; -if (!("sceneReady" in getroottable())) sceneReady <- false; +persist prevKeys = {} +persist uiReady = false +persist curSceneName = "town" +persist sceneReady = false function edgePressed(key, down) { local was = (key in prevKeys) ? prevKeys[key] : false; diff --git a/examples/dynamic-water-grid/README.md b/examples/dynamic-water-grid/README.md new file mode 100644 index 00000000..d24f58f1 --- /dev/null +++ b/examples/dynamic-water-grid/README.md @@ -0,0 +1,94 @@ +# Dual-grid dynamic water + +![Dynamic water over replaceable C14 terrain](runtime-capture.png) + +Runnable 2.5D water prototype with two deliberately separate data paths: + +- `WaterField.amount[]` is the CPU-authoritative per-cell water quantity. +- `assets/riverbed.png` is the supplied 4×4 isometric dual-grid sheet. Its 16 + skewed diamonds are unpacked into a conventional atlas at load time. +- `assets/terrain/c14-land-atlas.png` supplies four interchangeable land tiles: + yellow grass, green grass, sand and mud. +- An invisible logical `TileLayer` marks wet riverbed cells as filled. +- `Map.resolveDualGrid()` derives the half-cell-offset riverbed display layer; + black source pixels are transparent and reveal the selected land beneath. +- White-haired and blue-haired characters each use a `6×8` transparent atlas: + eight directions, six walking frames per direction, with shared feet anchors. +- A `16×12` Canvas mirrors depth and flow as an RGBA data texture. +- A Vulkan GLSL / WebGPU WGSL shader inverse-projects each screen pixel into + logical isometric coordinates and samples the four shared water nodes. + +This separation means ordinary level changes only update the tiny data Canvas. +The dual-grid shore is resolved again only when a cell crosses the wet threshold. + +## Run + +```powershell +make run/win32-debug GAME=examples/dynamic-water-grid +``` + +Controls: + +- Left mouse: add water to the hovered logical cell. +- Right mouse: drain water. +- `Space`: pause/resume simulation. +- `R`: reset the winding river. +- `F`: toggle the inlet source. +- `N`: show the authoritative per-cell sample nodes. +- Arrow keys / `WASD`: preview both characters' eight facing directions. + +The simulation processes each undirected grid edge once and writes transfers to +a delta buffer, so results do not depend on traversal direction. Each edge can +spend at most 22% of its source cell per fixed step, preventing four neighbours +from removing more water than exists. + +## Imported terrain and riverbed art + +The imported `assets/riverbed.png` is already active. `buildImportedShoreAtlas()` +extracts its 16 isometrically arranged `61×31` frames and repacks them row-major. +Neutral pixels below 15% brightness are color-keyed to transparency. Those dark +regions are land cut-outs, not water: they expose whichever terrain tile is +drawn below. The opaque tan pixels are the riverbed itself. + +The land atlas was imported from the supplied `C14 45°地表素材` pack using source +images `16.png`, `97.png`, `21.png` and `30.png`. The reproducible importer is +`assets/terrain/import_terrain_tiles.py`. + +The source positions are the standard SpriteCook/common dual-grid frame order, +so `Map.resolveDualGrid()` can select them with its default mask table. Frame 12 +is the unused empty state; masks 1–15 address the remaining frames. To replace +the sheet later, keep the same 4×4 isometric arrangement: + +```squirrel +shoreTexture = buildImportedShoreAtlas(); +shoreLayer.setTileset(shoreTexture, 1, 4); +shoreLayer.setTilesetTileSize(61, 31); +``` + +The display layer projects these source frames into `80×40` world cells with +nearest sampling. Rendering is explicitly split into three passes: land plus +riverbed, transparent water, then characters. + +Shader channel contract for the data Canvas: + +- R: normalized water depth. +- G: signed logical-X flow mapped from `[-1,1]` to `[0,1]`. +- B: signed logical-Y flow mapped from `[-1,1]` to `[0,1]`. +- A: reserved; currently `1`. + +Gameplay must continue reading `WaterField.amount`; the RGBA Canvas is a +quantized rendering mirror, not authoritative state. + +## Player walk atlas + +`assets/characters/white-haired-girl/walk-8dir.png` and +`assets/characters/blue-haired-girl/blue-haired-girl-walk-8dir-64.png` are the +game-ready atlases. Each has native `64×64` cells, six columns and eight rows +in this order: south, +southeast, east, northeast, north, northwest, west, southwest. The example +registers each region as a custom tile visual, so both sprites stay at their +native pixel scale and their feet remain fixed on their isometric tile centres. + +Only the two runtime atlases are part of the example. Generation prompts, +individual frames, previews and QA reports are intentionally kept out of the +repository. diff --git a/examples/dynamic-water-grid/assets/characters/blue-haired-girl/blue-haired-girl-walk-8dir-64.png b/examples/dynamic-water-grid/assets/characters/blue-haired-girl/blue-haired-girl-walk-8dir-64.png new file mode 100644 index 00000000..ead067dc Binary files /dev/null and b/examples/dynamic-water-grid/assets/characters/blue-haired-girl/blue-haired-girl-walk-8dir-64.png differ diff --git a/examples/dynamic-water-grid/assets/characters/white-haired-girl/walk-8dir.png b/examples/dynamic-water-grid/assets/characters/white-haired-girl/walk-8dir.png new file mode 100644 index 00000000..ecf8a9f5 Binary files /dev/null and b/examples/dynamic-water-grid/assets/characters/white-haired-girl/walk-8dir.png differ diff --git a/examples/dynamic-water-grid/assets/riverbed.png b/examples/dynamic-water-grid/assets/riverbed.png new file mode 100644 index 00000000..3241df95 Binary files /dev/null and b/examples/dynamic-water-grid/assets/riverbed.png differ diff --git a/examples/dynamic-water-grid/assets/terrain/c14-land-atlas.png b/examples/dynamic-water-grid/assets/terrain/c14-land-atlas.png new file mode 100644 index 00000000..c352cc6b Binary files /dev/null and b/examples/dynamic-water-grid/assets/terrain/c14-land-atlas.png differ diff --git a/examples/dynamic-water-grid/assets/terrain/import_terrain_tiles.py b/examples/dynamic-water-grid/assets/terrain/import_terrain_tiles.py new file mode 100644 index 00000000..5770d8f8 --- /dev/null +++ b/examples/dynamic-water-grid/assets/terrain/import_terrain_tiles.py @@ -0,0 +1,106 @@ +"""Build preview and EVEngine terrain atlas from the supplied C14 45-degree set.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image, ImageDraw + + +TILE_WIDTH = 80 +TILE_HEIGHT = 40 + + +def numeric_pngs(root: Path) -> list[Path]: + return sorted( + (path for path in root.glob("*.png") if path.stem.isdigit()), + key=lambda path: int(path.stem), + ) + + +def make_contact_sheet(files: list[Path], output: Path) -> None: + thumb_width, thumb_height = 160, 120 + columns = 6 + rows = (len(files) + columns - 1) // columns + sheet = Image.new("RGB", (columns * thumb_width, rows * (thumb_height + 20)), "#181a1d") + draw = ImageDraw.Draw(sheet) + for index, path in enumerate(files): + image = Image.open(path).convert("RGB") + image.thumbnail((thumb_width, thumb_height), Image.Resampling.LANCZOS) + x = index % columns * thumb_width + (thumb_width - image.width) // 2 + y = index // columns * (thumb_height + 20) + sheet.paste(image, (x, y)) + draw.text((index % columns * thumb_width + 5, y + thumb_height + 2), path.stem, fill="white") + output.parent.mkdir(parents=True, exist_ok=True) + sheet.save(output) + + +def find_diamond_bbox(image: Image.Image) -> tuple[int, int, int, int]: + # All source previews are 800x600 and place the material diamond around the + # same centre. Sample slightly inside the visible diamond: scaling that + # inner region back to the tile mask overscans the antialiased grey edge and + # prevents hairline seams between neighbouring isometric cells. + return ( + round(image.width * 0.06), + round(image.height * 0.085), + round(image.width * 0.94), + round(image.height * 0.915), + ) + + +def extract_tile(path: Path) -> Image.Image: + image = Image.open(path).convert("RGB") + sampled = image.crop(find_diamond_bbox(image)).resize( + (TILE_WIDTH, TILE_HEIGHT), Image.Resampling.LANCZOS + ).convert("RGB") + source_pixels = sampled.load() + tile = Image.new("RGBA", (TILE_WIDTH, TILE_HEIGHT), (0, 0, 0, 0)) + pixels = tile.load() + centre_x = (TILE_WIDTH - 1) * 0.5 + centre_y = (TILE_HEIGHT - 1) * 0.5 + for y in range(TILE_HEIGHT): + for x in range(TILE_WIDTH): + distance = abs((x + 0.5 - TILE_WIDTH * 0.5) / (TILE_WIDTH * 0.5)) + distance += abs((y + 0.5 - TILE_HEIGHT * 0.5) / (TILE_HEIGHT * 0.5)) + # One-pixel overlap hides rasterization cracks between neighbouring + # 80x40 diamonds while still leaving the atlas corners transparent. + if distance <= 1.06: + sample_x, sample_y = x, y + if distance > 0.90: + inward = 0.90 / distance + sample_x = round(centre_x + (x - centre_x) * inward) + sample_y = round(centre_y + (y - centre_y) * inward) + r, g, b = source_pixels[sample_x, sample_y] + pixels[x, y] = (r, g, b, 255) + return tile + + +def build_atlas(root: Path, selections: list[str], output: Path) -> None: + atlas = Image.new("RGBA", (TILE_WIDTH * len(selections), TILE_HEIGHT), (0, 0, 0, 0)) + for index, stem in enumerate(selections): + atlas.alpha_composite(extract_tile(root / f"{stem}.png"), (index * TILE_WIDTH, 0)) + output.parent.mkdir(parents=True, exist_ok=True) + atlas.save(output) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("source_dir", type=Path) + parser.add_argument("--contact-sheet", type=Path) + parser.add_argument("--output", type=Path) + parser.add_argument("--select", nargs="+", help="Four numeric file stems") + args = parser.parse_args() + files = numeric_pngs(args.source_dir) + if args.contact_sheet: + make_contact_sheet(files, args.contact_sheet) + print(f"wrote contact sheet with {len(files)} candidates: {args.contact_sheet}") + if args.output: + if not args.select or len(args.select) != 4: + parser.error("--output requires exactly four --select values") + build_atlas(args.source_dir, args.select, args.output) + print(f"wrote terrain atlas from {args.select}: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/examples/dynamic-water-grid/config.nut b/examples/dynamic-water-grid/config.nut new file mode 100644 index 00000000..a43f0d76 --- /dev/null +++ b/examples/dynamic-water-grid/config.nut @@ -0,0 +1,8 @@ +config = { + width = 1280 + height = 720 + title = "EVEngine Dual-Grid Dynamic Water" + hotReload = true + debug = false + modules = ["gfx", "win", "fs", "map", "ui", "keyboard", "mouse"] +}; diff --git a/examples/dynamic-water-grid/main.nut b/examples/dynamic-water-grid/main.nut new file mode 100644 index 00000000..2546b309 --- /dev/null +++ b/examples/dynamic-water-grid/main.nut @@ -0,0 +1,426 @@ +dofile("water_field.nut"); + +persist waterDemo = null +persist waterField = null +persist waterMap = null +persist logicLayer = null +persist terrainLayer = null +persist shoreLayer = null +persist heroLayer = null +persist companionLayer = null +persist fieldCanvas = null +persist waterShader = null +persist shoreTexture = null +persist terrainTexture = null +persist heroTexture = null +persist companionTexture = null +persist previousKeys = {} +persist paused = false +persist sourceEnabled = true +persist showNodes = false +persist elapsed = 0.0 +persist fixedClock = 0.0 +persist heroClock = 0.0 +persist heroDirection = 0 +persist hudBuilt = false + +const FIELD_W = 16; +const FIELD_H = 12; +const TILE_W = 80.0; +const TILE_H = 40.0; +const ORIGIN_X = 650.0; +const ORIGIN_Y = 100.0; +const GID_RIVERBED = 1; +const FIXED_DT = 0.0333333; +const PATCH_SOURCE_W = 244; +const PATCH_SOURCE_H = 123; +const ATLAS_TILE_W = 61; +const ATLAS_TILE_H = 31; +const HERO_CELL = 64; +const HERO_COLS = 6; +const HERO_ROWS = 8; +// Squirrel const values must be scalar literals, not expressions. +const HERO_FRAMES = 48; +const HERO_X = 7; +const HERO_Y = 6; +const COMPANION_X = 9; +const COMPANION_Y = 6; + + +function edgePressed(name) { + local down = keyboard.isDown(name); + local old = (name in previousKeys) ? previousKeys[name] : false; + previousKeys[name] <- down; + return down && !old; +} + +function rgbaPixel(target, x, y, r, g, b, a) { + target.setPixel(x, y, r, g, b, a); +} + +function buildImportedShoreAtlas() { + local imageModule = eve.Image(); + local src = imageModule.newImageDataFromFile("assets/riverbed.png"); + local atlas = imageModule.newEmptyImageData( + ATLAS_TILE_W * 4, ATLAS_TILE_H * 4, "RGBA8"); + + // The supplied sheet lays its 16 frames out as an isometric 4x4 map. + // Unskew every diamond into a conventional row-major atlas. + for (local row = 0; row < 4; ++row) { + for (local col = 0; col < 4; ++col) { + local centerX = PATCH_SOURCE_W * 0.5 + + (col - row) * ATLAS_TILE_W * 0.5; + local sourceX = (centerX - ATLAS_TILE_W * 0.5 + 0.5).tointeger(); + local sourceY = ((col + row) * PATCH_SOURCE_H / 8.0 + 0.5).tointeger(); + local targetX = col * ATLAS_TILE_W; + local targetY = row * ATLAS_TILE_H; + + for (local py = 0; py < ATLAS_TILE_H; ++py) { + for (local px = 0; px < ATLAS_TILE_W; ++px) { + local diamond = abs((px + 0.5 - ATLAS_TILE_W * 0.5) / + (ATLAS_TILE_W * 0.5)) + + abs((py + 0.5 - ATLAS_TILE_H * 0.5) / + (ATLAS_TILE_H * 0.5)); + if (diamond > 1.0) continue; + local sx = sourceX + px; + local sy = sourceY + py; + if (sx < 0 || sy < 0 || sx >= PATCH_SOURCE_W || sy >= PATCH_SOURCE_H) + continue; + local r = src.getPixelR(sx, sy); + local g = src.getPixelG(sx, sy); + local b = src.getPixelB(sx, sy); + local a = src.getPixelA(sx, sy); + local neutral = abs(r - g) < 0.012 && abs(g - b) < 0.012; + local alpha = (neutral && r < 0.15) ? 0.0 : a; + rgbaPixel(atlas, targetX + px, targetY + py, r, g, b, alpha); + } + } + } + } + local tex = gfx.newTexture(atlas, false, false); + gfx.setTextureSampler(tex, "nearest", "none", 1.0, 0.0); + return tex; +} + +function buildTerrainAtlas() { + // Imported from the user's C14 45-degree terrain pack. Atlas order: + // yellow grass, green grass, sand, mud. riverbed.png's black regions are + // transparent and reveal these tiles unchanged. + local texture = gfx.newTextureFromFile("assets/terrain/c14-land-atlas.png"); + gfx.setTextureSampler(texture, "nearest", "none", 1.0, 0.0); + return texture; +} + +function configureIsoLayer(layer, drawLayer) { + layer.applyConfig(@"{ + ""orientation"":""isometric"", + ""tilewidth"":80,""tileheight"":40, + ""offsetx"":650,""offsety"":100 + }"); + layer.setOrigin(ORIGIN_X, ORIGIN_Y); + layer.setLayer(drawLayer); +} + +function configureCharacterLayer(layer, texture) { + configureIsoLayer(layer, 1); + gfx.setTextureSampler(texture, "nearest", "none", 1.0, 0.0); + layer.setTileset(texture, 1, HERO_COLS, 0, 0); + layer.setTilesetTileSize(HERO_CELL, HERO_CELL); + + // Every frame shares one feet anchor at the projected tile centre. Custom + // visual regions retain the native 64px sprite cell while the map remains + // an 80x40 isometric grid. + for (local row = 0; row < HERO_ROWS; ++row) { + for (local col = 0; col < HERO_COLS; ++col) { + local gid = 1 + row * HERO_COLS + col; + layer.setTileVisual(gid, col * HERO_CELL, row * HERO_CELL, + HERO_CELL, HERO_CELL, -8.0, 24.0, 1.0); + } + } +} + +function configureHeroLayer() { + heroLayer = waterMap.newLayer(FIELD_W, FIELD_H, TILE_W, TILE_H); + heroTexture = gfx.newTextureFromFile("assets/characters/white-haired-girl/walk-8dir.png"); + configureCharacterLayer(heroLayer, heroTexture); + heroLayer.setTile(HERO_X, HERO_Y, 1); + + companionLayer = waterMap.newLayer(FIELD_W, FIELD_H, TILE_W, TILE_H); + companionTexture = gfx.newTextureFromFile( + "assets/characters/blue-haired-girl/blue-haired-girl-walk-8dir-64.png"); + configureCharacterLayer(companionLayer, companionTexture); + companionLayer.setTile(COMPANION_X, COMPANION_Y, 1); +} + +function updateHero(dt) { + if (heroLayer == null || companionLayer == null) return; + heroClock += dt; + // Arrow keys preview all eight rows without changing the water simulation. + local left = keyboard.isDown("left") || keyboard.isDown("a") || keyboard.isDown("A"); + local right = keyboard.isDown("right") || keyboard.isDown("d") || keyboard.isDown("D"); + local up = keyboard.isDown("up") || keyboard.isDown("w") || keyboard.isDown("W"); + local down = keyboard.isDown("down") || keyboard.isDown("s") || keyboard.isDown("S"); + if (down && !left && !right) heroDirection = 0; + else if (down && right) heroDirection = 1; + else if (right && !up && !down) heroDirection = 2; + else if (up && right) heroDirection = 3; + else if (up && !left && !right) heroDirection = 4; + else if (up && left) heroDirection = 5; + else if (left && !up && !down) heroDirection = 6; + else if (down && left) heroDirection = 7; + local frame = (heroClock / 0.12).tointeger() % HERO_COLS; + local gid = 1 + heroDirection * HERO_COLS + frame; + heroLayer.setTile(HERO_X, HERO_Y, gid); + companionLayer.setTile(COMPANION_X, COMPANION_Y, gid); +} + +function loadWaterShader() { + local wgsl = fs.readText("shaders/water_field.wgsl"); + local glsl = fs.readText("shaders/water_field.frag"); + local shader = null; + try { + shader = gfx.newShaderFromSpvFile("shaders/water_field.frag.spv"); + } catch (vulkanError) { + try { + shader = gfx.newShaderFromWgsl("", wgsl); + } catch (webgpuError) { + shader = gfx.newShader(glsl); + } + } + shader.declareVec2("screenSize"); + shader.declareVec2("origin"); + shader.declareVec2("tileSize"); + shader.declareVec2("fieldSize"); + shader.declareFloat("time"); + shader.declareFloat("wetThreshold"); + shader.declareFloat("opacity"); + return shader; +} + +function syncTopology(force) { + local changed = force; + for (local y = 0; y < FIELD_H; ++y) { + for (local x = 0; x < FIELD_W; ++x) { + // Filled dual-grid corners mean riverbed, not land. The atlas's + // black pixels stay transparent and reveal terrainLayer below. + local gid = waterField.isWet(x, y) ? GID_RIVERBED : 0; + if (logicLayer.getTile(x, y) != gid) { + logicLayer.setTile(x, y, gid); + changed = true; + } + } + } + if (changed && !waterMap.resolveDualGrid(logicLayer, shoreLayer)) + print("dynamic-water-grid: dual-grid resolve failed\n"); +} + +function rebuildExample() { + waterField = WaterField(FIELD_W, FIELD_H); + waterField.resetRiver(); + + waterMap = eve.Map(); + logicLayer = waterMap.newLayer(FIELD_W, FIELD_H, TILE_W, TILE_H); + configureIsoLayer(logicLayer, -1); + + terrainLayer = waterMap.newLayer(FIELD_W, FIELD_H, TILE_W, TILE_H); + configureIsoLayer(terrainLayer, -2); + terrainTexture = buildTerrainAtlas(); + terrainLayer.setTileset(terrainTexture, 1, 4, 0, 0); + terrainLayer.setTilesetTileSize(TILE_W.tointeger(), TILE_H.tointeger()); + for (local y = 0; y < FIELD_H; ++y) { + for (local x = 0; x < FIELD_W; ++x) { + local material = (x < FIELD_W / 2 ? 0 : 1) + + (y < FIELD_H / 2 ? 0 : 2); + terrainLayer.setTile(x, y, 1 + material); + } + } + + shoreLayer = waterMap.newLayer(1, 1, TILE_W, TILE_H); + configureIsoLayer(shoreLayer, -1); + shoreTexture = buildImportedShoreAtlas(); + shoreLayer.setTileset(shoreTexture, 1, 4, 0, 0); + shoreLayer.setTilesetTileSize(ATLAS_TILE_W, ATLAS_TILE_H); + configureHeroLayer(); + + fieldCanvas = gfx.newCanvas(FIELD_W, FIELD_H); + waterShader = loadWaterShader(); + syncTopology(true); +} + +function rasterizeWaterField() { + gfx.setShader(null); + gfx.setCanvas(fieldCanvas); + for (local y = 0; y < FIELD_H; ++y) { + for (local x = 0; x < FIELD_W; ++x) { + local i = waterField.index(x, y); + local depth = waterField.normalizedDepth(x, y); + local vx = clampValue(waterField.flowX[i] * 18.0, -1.0, 1.0); + local vy = clampValue(waterField.flowY[i] * 18.0, -1.0, 1.0); + gfx.drawSolidRect(x.tofloat(), y.tofloat(), 1.0, 1.0, + depth, vx * 0.5 + 0.5, vy * 0.5 + 0.5, 1.0); + } + } + gfx.setCanvas(null); +} + +function updateShaderUniforms() { + waterShader.sendVec2("screenSize", config.width.tofloat(), config.height.tofloat()); + // TileProjection returns the top-left of each 80x40 bounding box. The + // diamond centre is half a tile farther right/down. + waterShader.sendVec2("origin", ORIGIN_X + TILE_W * 0.5, ORIGIN_Y + TILE_H * 0.5); + waterShader.sendVec2("tileSize", TILE_W, TILE_H); + waterShader.sendVec2("fieldSize", FIELD_W.tofloat(), FIELD_H.tofloat()); + waterShader.sendFloat("time", elapsed); + waterShader.sendFloat("wetThreshold", waterField.wetThreshold / waterField.maxVisualDepth); + waterShader.sendFloat("opacity", 0.92); +} + +function mouseCell() { + if (logicLayer == null) return [-1, -1]; + return [logicLayer.worldToTileX(mouse.getX() - TILE_W * 0.5, + mouse.getY() - TILE_H * 0.5), + logicLayer.worldToTileY(mouse.getX() - TILE_W * 0.5, + mouse.getY() - TILE_H * 0.5)]; +} + +function updateHud() { + if (!hudBuilt || waterField == null) return; + local cell = mouseCell(); + local amount = waterField.getAmount(cell[0], cell[1]); + local total10 = (waterField.totalAmount() * 10.0).tointeger(); + local amount100 = (amount * 100.0).tointeger(); + ui.setText("stats", "cell (" + cell[0] + ", " + cell[1] + ") water=" + + amount100 + "% total=" + total10 + "/10"); + ui.setText("mode", (paused ? "PAUSED" : "SIMULATING") + + " source=" + (sourceEnabled ? "ON" : "OFF") + + " nodes=" + (showNodes ? "ON" : "OFF")); +} + +function buildHud() { + if (hudBuilt) return; + ui.beginBuild(); + ui.beginWindow("Dynamic Water Grid", "root"); + ui.text("Dual-grid + per-cell water amount", "title"); + ui.text("", "stats"); + ui.text("", "mode"); + ui.text("LMB add water | RMB drain | drag supported", "mouseHelp"); + ui.text("Space pause | R reset | F source | N node markers", "keyHelp"); + ui.text("Arrow / WASD preview both heroes' 8 walk directions", "heroHelp"); + ui.text("Land below | riverbed dual-grid | transparent water above", "assetHelp"); + ui.end(); + ui.mountBuildAs("water-hud"); + ui.select("water-hud"); + ui.setHostOverlay(true); + ui.setHostPos(14.0, 12.0, 0.0, 0.0); + hudBuilt = true; +} + +eve_init = function() { + gfx.setBackgroundColor(0.045, 0.055, 0.065, 1.0); + rebuildExample(); + buildHud(); + updateHud(); + print("dynamic-water-grid ready: LMB add, RMB drain, Space pause\n"); +}; + +eve_reload <- function() { + waterShader = loadWaterShader(); + syncTopology(true); +}; + +eve_asset_reload <- function(path) { + if (path.find("water_field") != null) + waterShader = loadWaterShader(); + if (path.find("riverbed.png") != null) { + shoreTexture = buildImportedShoreAtlas(); + shoreLayer.setTileset(shoreTexture, 1, 4, 0, 0); + shoreLayer.setTilesetTileSize(ATLAS_TILE_W, ATLAS_TILE_H); + syncTopology(true); + } + if (path.find("c14-land-atlas.png") != null) { + terrainTexture = buildTerrainAtlas(); + terrainLayer.setTileset(terrainTexture, 1, 4, 0, 0); + terrainLayer.setTilesetTileSize(TILE_W.tointeger(), TILE_H.tointeger()); + } +}; + +eve_update = function(dt) { + elapsed += dt; + if (edgePressed("space")) paused = !paused; + if (edgePressed("r") || edgePressed("R")) { + waterField.resetRiver(); + syncTopology(true); + } + if (edgePressed("f") || edgePressed("F")) sourceEnabled = !sourceEnabled; + if (edgePressed("n") || edgePressed("N")) showNodes = !showNodes; + + local cell = mouseCell(); + if (mouse.isDown(1)) waterField.addAmount(cell[0], cell[1], dt * 1.8); + if (mouse.isDown(2)) waterField.addAmount(cell[0], cell[1], -dt * 2.4); + + if (!paused) { + fixedClock += minValue(dt, 0.1); + while (fixedClock >= FIXED_DT) { + if (sourceEnabled) { + local sx = 1; + local sy = waterField.channelCenter(sx).tointeger(); + waterField.addAmount(sx, sy, 0.075 * FIXED_DT); + } + local topologyChanged = waterField.step(FIXED_DT); + if (topologyChanged) syncTopology(false); + fixedClock -= FIXED_DT; + } + } else if (waterField.refreshWetState()) { + syncTopology(false); + } + + waterMap.update(dt); + updateHero(dt); + updateHud(); +}; + +eve_render = function() { + // Update the offscreen data texture before starting any swapchain draws. + // Switching back to an offscreen Canvas after screen batches were queued + // would reopen the Vulkan frame target and discard the earlier pass. + rasterizeWaterField(); + updateShaderUniforms(); + + gfx.clear(); + + // Pass 1: replaceable land materials, then the beige riverbed overlay. + terrainLayer.setVisible(true); + shoreLayer.setVisible(true); + heroLayer.setVisible(false); + companionLayer.setVisible(false); + waterMap.render(gfx); + + // Pass 2: transparent animated water exists only where amount > threshold. + gfx.setShader(waterShader); + gfx.drawCanvas(fieldCanvas, 0.0, 0.0, config.width.tofloat(), config.height.tofloat()); + gfx.setShader(null); + + // Pass 3: characters stay above the water surface. + terrainLayer.setVisible(false); + shoreLayer.setVisible(false); + heroLayer.setVisible(true); + companionLayer.setVisible(true); + waterMap.render(gfx); + terrainLayer.setVisible(true); + shoreLayer.setVisible(true); + + if (showNodes) { + for (local y = 0; y < FIELD_H; ++y) { + for (local x = 0; x < FIELD_W; ++x) { + local wx = logicLayer.tileToWorldX(x, y); + local wy = logicLayer.tileToWorldY(x, y); + local d = waterField.normalizedDepth(x, y); + gfx.drawSolidRect(wx + TILE_W * 0.5 - 2.0, + wy + TILE_H * 0.5 - 2.0, 5.0, 5.0, + 1.0 - d, 0.25 + d * 0.65, 0.95, 1.0); + } + } + } + + ui.beginFrameAndRender(); +}; diff --git a/examples/dynamic-water-grid/runtime-capture.png b/examples/dynamic-water-grid/runtime-capture.png new file mode 100644 index 00000000..3c8930cd Binary files /dev/null and b/examples/dynamic-water-grid/runtime-capture.png differ diff --git a/examples/dynamic-water-grid/shaders/water_field.frag b/examples/dynamic-water-grid/shaders/water_field.frag new file mode 100644 index 00000000..96e842a8 --- /dev/null +++ b/examples/dynamic-water-grid/shaders/water_field.frag @@ -0,0 +1,61 @@ +#version 450 + +layout(location = 0) in vec4 fragColor; +layout(location = 1) in vec2 fragUV; +layout(location = 0) out vec4 outColor; +layout(binding = 0) uniform sampler2D MainTex; + +layout(push_constant) uniform Externals { + float data[32]; +} u; + +void main() { + vec2 screenSize = vec2(u.data[0], u.data[1]); + vec2 origin = vec2(u.data[2], u.data[3]); + vec2 tileSize = vec2(u.data[4], u.data[5]); + vec2 fieldSize = vec2(u.data[6], u.data[7]); + float time = u.data[8]; + float wetThreshold = u.data[9]; + float opacity = u.data[10]; + + // Inverse of EVEngine's isometric TileProjection. Water samples are stored + // at logical-grid nodes; the dual-grid shore tiles live between four nodes. + vec2 screen = floor(fragUV * screenSize) + vec2(0.5); + float a = (screen.x - origin.x) / max(tileSize.x * 0.5, 0.0001); + float b = (screen.y - origin.y) / max(tileSize.y * 0.5, 0.0001); + vec2 grid = vec2((b + a) * 0.5, (b - a) * 0.5); + + if (grid.x < -0.5 || grid.y < -0.5 || + grid.x > fieldSize.x - 0.5 || grid.y > fieldSize.y - 0.5) { + outColor = vec4(0.0); + return; + } + + vec2 dataUV = (grid + vec2(0.5)) / fieldSize; + vec4 field = texture(MainTex, dataUV); + float depth = field.r; + vec2 flow = field.gb * 2.0 - 1.0; + + float wave = sin(screen.x * 0.075 + screen.y * 0.031 + time * 2.25 + flow.x * 2.0); + wave += sin(screen.x * -0.028 + screen.y * 0.093 + time * 1.37 + flow.y * 2.0); + wave *= 0.5; + + float threshold = wetThreshold + wave * 0.009; + float coverage = smoothstep(threshold - 0.018, threshold + 0.025, depth); + if (coverage <= 0.001) { + outColor = vec4(0.0); + return; + } + + float deep = smoothstep(0.10, 0.82, depth); + vec3 shallowColor = vec3(0.18, 0.55, 0.62); + vec3 deepColor = vec3(0.035, 0.20, 0.34); + vec3 waterColor = mix(shallowColor, deepColor, deep); + + float contour = 1.0 - smoothstep(0.015, 0.065, abs(depth - threshold - 0.035)); + float glint = max(0.0, sin(screen.x * 0.16 - screen.y * 0.07 + time * 3.1)); + waterColor += vec3(0.42, 0.31, 0.46) * contour * (0.45 + glint * 0.35); + + float alpha = coverage * opacity * mix(0.56, 0.84, deep); + outColor = vec4(waterColor * fragColor.rgb, alpha * fragColor.a); +} diff --git a/examples/dynamic-water-grid/shaders/water_field.frag.spv b/examples/dynamic-water-grid/shaders/water_field.frag.spv new file mode 100644 index 00000000..51e34d40 Binary files /dev/null and b/examples/dynamic-water-grid/shaders/water_field.frag.spv differ diff --git a/examples/dynamic-water-grid/shaders/water_field.wgsl b/examples/dynamic-water-grid/shaders/water_field.wgsl new file mode 100644 index 00000000..f2d12777 --- /dev/null +++ b/examples/dynamic-water-grid/shaders/water_field.wgsl @@ -0,0 +1,64 @@ +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; + +fn p(i: u32) -> f32 { + return u.data[i / 4u][i % 4u]; +} + +@fragment +fn fs_main(input: FSIn) -> @location(0) vec4f { + let screenSize = vec2f(p(0u), p(1u)); + let origin = vec2f(p(2u), p(3u)); + let tileSize = vec2f(p(4u), p(5u)); + let fieldSize = vec2f(p(6u), p(7u)); + let time = p(8u); + let wetThreshold = p(9u); + let opacity = p(10u); + + let screen = floor(input.uv * screenSize) + vec2f(0.5); + let a = (screen.x - origin.x) / max(tileSize.x * 0.5, 0.0001); + let b = (screen.y - origin.y) / max(tileSize.y * 0.5, 0.0001); + let grid = vec2f((b + a) * 0.5, (b - a) * 0.5); + + if grid.x < -0.5 || grid.y < -0.5 || + grid.x > fieldSize.x - 0.5 || grid.y > fieldSize.y - 0.5 { + return vec4f(0.0); + } + + let dataUV = (grid + vec2f(0.5)) / fieldSize; + let field = textureSampleLevel(mainTex, mainSamp, dataUV, 0.0); + let depth = field.r; + let flow = field.gb * 2.0 - 1.0; + + var wave = sin(screen.x * 0.075 + screen.y * 0.031 + time * 2.25 + flow.x * 2.0); + wave += sin(screen.x * -0.028 + screen.y * 0.093 + time * 1.37 + flow.y * 2.0); + wave *= 0.5; + + let threshold = wetThreshold + wave * 0.009; + let coverage = smoothstep(threshold - 0.018, threshold + 0.025, depth); + if coverage <= 0.001 { + return vec4f(0.0); + } + + let deep = smoothstep(0.10, 0.82, depth); + let shallowColor = vec3f(0.18, 0.55, 0.62); + let deepColor = vec3f(0.035, 0.20, 0.34); + var waterColor = mix(shallowColor, deepColor, deep); + + let contour = 1.0 - smoothstep(0.015, 0.065, abs(depth - threshold - 0.035)); + let glint = max(0.0, sin(screen.x * 0.16 - screen.y * 0.07 + time * 3.1)); + waterColor += vec3f(0.42, 0.31, 0.46) * contour * (0.45 + glint * 0.35); + + let alpha = coverage * opacity * mix(0.56, 0.84, deep); + return vec4f(waterColor * input.color.rgb, alpha * input.color.a); +} diff --git a/examples/dynamic-water-grid/water_field.nut b/examples/dynamic-water-grid/water_field.nut new file mode 100644 index 00000000..a2383a02 --- /dev/null +++ b/examples/dynamic-water-grid/water_field.nut @@ -0,0 +1,171 @@ +// CPU-authoritative conservative water field. +// Rendering consumes a quantized copy; gameplay always reads these float arrays. + +function minValue(a, b) { return a < b ? a : b; } + +function clampValue(v, lo, hi) { + return v < lo ? lo : (v > hi ? hi : v); +} + +class WaterField { + width = 0; + height = 0; + amount = null; + bed = null; + delta = null; + flowX = null; + flowY = null; + wet = null; + maxVisualDepth = 1.0; + wetThreshold = 0.035; + flowRate = 2.4; + + constructor(w, h) { + width = w; + height = h; + local count = w * h; + amount = array(count, 0.0); + bed = array(count, 0.0); + delta = array(count, 0.0); + flowX = array(count, 0.0); + flowY = array(count, 0.0); + wet = array(count, false); + } + + function index(x, y) { return y * width + x; } + + function inside(x, y) { + return x >= 0 && y >= 0 && x < width && y < height; + } + + function clamp01(v) { + if (v < 0.0) return 0.0; + if (v > 1.0) return 1.0; + return v; + } + + function clear() { + for (local i = 0; i < amount.len(); ++i) { + amount[i] = 0.0; + delta[i] = 0.0; + flowX[i] = 0.0; + flowY[i] = 0.0; + wet[i] = false; + } + } + + function channelCenter(x) { + return height * 0.48 + sin(x * 0.58) * 1.55 + sin(x * 0.19) * 0.65; + } + + function resetRiver() { + clear(); + for (local y = 0; y < height; ++y) { + for (local x = 0; x < width; ++x) { + local i = index(x, y); + local distance = abs(y - channelCenter(x)); + // High banks and a gently descending river bed. + bed[i] = 0.78 + distance * 0.075 + x * 0.005; + if (distance < 2.35) + bed[i] = 0.12 + distance * 0.055 + x * 0.004; + if (distance < 1.35) + amount[i] = 0.42 + (1.35 - distance) * 0.16; + } + } + refreshWetState(); + } + + function getAmount(x, y) { + if (!inside(x, y)) return 0.0; + return amount[index(x, y)]; + } + + function getBed(x, y) { + if (!inside(x, y)) return 1000.0; + return bed[index(x, y)]; + } + + function getSurface(x, y) { + if (!inside(x, y)) return 1000.0; + local i = index(x, y); + return bed[i] + amount[i]; + } + + function addAmount(x, y, value) { + if (!inside(x, y)) return; + local i = index(x, y); + amount[i] = clampValue(amount[i] + value, 0.0, 1.75); + } + + function normalizedDepth(x, y) { + return clamp01(getAmount(x, y) / maxVisualDepth); + } + + function isWet(x, y) { + return inside(x, y) && amount[index(x, y)] > wetThreshold; + } + + function refreshWetState() { + local changed = false; + for (local i = 0; i < amount.len(); ++i) { + local nowWet = amount[i] > wetThreshold; + if (nowWet != wet[i]) changed = true; + wet[i] = nowWet; + } + return changed; + } + + function transferPair(ax, ay, bx, by, dirX, dirY, dt) { + local a = index(ax, ay); + local b = index(bx, by); + local difference = (bed[a] + amount[a]) - (bed[b] + amount[b]); + if (abs(difference) < 0.0001) return; + + if (difference > 0.0) { + // At most 22% per edge: four neighbours cannot overspend the cell. + local q = minValue(difference * flowRate * dt, amount[a] * 0.22); + delta[a] -= q; + delta[b] += q; + flowX[a] += dirX * q; + flowY[a] += dirY * q; + flowX[b] += dirX * q; + flowY[b] += dirY * q; + } else { + local q = minValue(-difference * flowRate * dt, amount[b] * 0.22); + delta[b] -= q; + delta[a] += q; + flowX[a] -= dirX * q; + flowY[a] -= dirY * q; + flowX[b] -= dirX * q; + flowY[b] -= dirY * q; + } + } + + function step(dt) { + for (local i = 0; i < amount.len(); ++i) { + delta[i] = 0.0; + flowX[i] = 0.0; + flowY[i] = 0.0; + } + + // Process every undirected edge once. All writes go to delta so the + // result does not depend on traversal direction. + for (local y = 0; y < height; ++y) { + for (local x = 0; x < width; ++x) { + if (x + 1 < width) transferPair(x, y, x + 1, y, 1.0, 0.0, dt); + if (y + 1 < height) transferPair(x, y, x, y + 1, 0.0, 1.0, dt); + } + } + + for (local i = 0; i < amount.len(); ++i) + amount[i] = clampValue(amount[i] + delta[i], 0.0, 1.75); + + return refreshWetState(); + } + + function totalAmount() { + local total = 0.0; + foreach (value in amount) total += value; + return total; + } +} diff --git a/examples/editor-api-v2/main.nut b/examples/editor-api-v2/main.nut index accc41dc..7ffd1ac1 100644 --- a/examples/editor-api-v2/main.nut +++ b/examples/editor-api-v2/main.nut @@ -10,8 +10,7 @@ const CELL = 52.0; const COLS = 12; const ROWS = 11; -editorV2 <- persist("editorV2", function() { - return { +persist editorV2 = { editor = null session = null sceneCanvas = null @@ -22,8 +21,7 @@ editorV2 <- persist("editorV2", function() { revision = 0 lastMouseDown = false status = "Choose an asset and click the scene" - }; -}); + } assets <- [ { id = "park.asset.tree", label = "Tree", cost = 20 }, diff --git a/examples/editor-ui-gallery/main.nut b/examples/editor-ui-gallery/main.nut index 981cdd33..03dc87c7 100644 --- a/examples/editor-ui-gallery/main.nut +++ b/examples/editor-ui-gallery/main.nut @@ -1,6 +1,6 @@ // Complete desktop-editor composition built from the reusable UI primitives. -if (!("darkTheme" in getroottable())) darkTheme <- false; +persist darkTheme = false function buildGallery() { // `switch` is a Squirrel keyword, so bind the native method explicitly. diff --git a/examples/galgame/main.nut b/examples/galgame/main.nut index 6cd42388..87160f20 100644 --- a/examples/galgame/main.nut +++ b/examples/galgame/main.nut @@ -1,7 +1,6 @@ // 潮汐电台:一段可分支、可存读档、可回看的原创 Galgame 短篇。 -gal <- persist("gal", function() { - return { +persist gal = { mode = "title", step = 0, route = "", trust = 0, ending = "", uiMode = "", lineRecorded = false, toast = "", toastLeft = 0.0, mouseWas = false, touchWas = false, @@ -11,8 +10,7 @@ gal <- persist("gal", function() { fx = "rain", time = 0.0, effectTime = 0.0, transition = 0.0, entrance = 0.0, shake = 0.0, chapterLeft = 0.0, qaRoute = "" - }; -}); + } local story = [ { who = "", text = "雨停在末班车到站前。七年没有回来的海鸣站,仍在播放那段无人认领的电台杂音。", scene = "station", fx = "rain", chapter = "序章 · 归站" }, diff --git a/examples/hex-levels/main.nut b/examples/hex-levels/main.nut index eebb5d7d..828b054f 100644 --- a/examples/hex-levels/main.nut +++ b/examples/hex-levels/main.nut @@ -17,56 +17,56 @@ // 运行: make run/-debug GAME=examples/hex-levels // ============================================================================ -if (!("layer" in getroottable())) layer <- null; -if (!("pf" in getroottable())) pf <- null; -if (!("fov" in getroottable())) fov <- null; -if (!("revealerId" in getroottable())) revealerId <- -1; -if (!("path" in getroottable())) path <- null; -if (!("hash" in getroottable())) hash <- null; -if (!("inv" in getroottable())) inv <- null; -if (!("bag" in getroottable())) bag <- null; -if (!("torchLight" in getroottable())) torchLight <- null; -if (!("cam" in getroottable())) cam <- null; -if (!("torchFx" in getroottable())) torchFx <- null; -if (!("sparkFx" in getroottable())) sparkFx <- null; -if (!("level" in getroottable())) level <- 1; -if (!("seed" in getroottable())) seed <- 42; -if (!("algo" in getroottable())) algo <- "dungeon.bsp"; -if (!("playerTx" in getroottable())) playerTx <- 0; -if (!("playerTy" in getroottable())) playerTy <- 0; -if (!("exitTx" in getroottable())) exitTx <- 0; -if (!("exitTy" in getroottable())) exitTy <- 0; -if (!("spawnTx" in getroottable())) spawnTx <- 0; -if (!("spawnTy" in getroottable())) spawnTy <- 0; -if (!("moveCd" in getroottable())) moveCd <- 0.0; -if (!("status" in getroottable())) status <- ""; -if (!("logLines" in getroottable())) logLines <- []; -if (!("loot" in getroottable())) loot <- []; -if (!("uiBuilt" in getroottable())) uiBuilt <- false; -if (!("pathLen" in getroottable())) pathLen <- 0; -if (!("visibleCount" in getroottable())) visibleCount <- 0; -if (!("exploredCount" in getroottable())) exploredCount <- 0; -if (!("flowPaths" in getroottable())) flowPaths <- []; -if (!("fovAlgo" in getroottable())) fovAlgo <- "shadowcast"; -if (!("torchRevealerId" in getroottable())) torchRevealerId <- -1; -if (!("mudCells" in getroottable())) mudCells <- []; -if (!("fixturesLoaded" in getroottable())) fixturesLoaded <- false; -if (!("activeLootTable" in getroottable())) activeLootTable <- "starter"; -if (!("lastCatalogLevel" in getroottable())) lastCatalogLevel <- -1; -if (!("levelCfg" in getroottable())) levelCfg <- null; -if (!("featureOn" in getroottable())) featureOn <- {}; -if (!("fovRadius" in getroottable())) fovRadius <- 6; -if (!("heroPerception" in getroottable())) heroPerception <- 0.0; -if (!("perceptionScale" in getroottable())) perceptionScale <- 0.0; -if (!("torchFovRadius" in getroottable())) torchFovRadius <- 3; -if (!("cellCostValue" in getroottable())) cellCostValue <- 8.0; -if (!("cellCostStrip" in getroottable())) cellCostStrip <- 4; -if (!("swarmStarts" in getroottable())) swarmStarts <- 4; -if (!("levelIdIndex" in getroottable())) levelIdIndex <- 0; -if (!("facingDeg" in getroottable())) facingDeg <- 0.0; -if (!("facingHalf" in getroottable())) facingHalf <- 0.0; -if (!("cornerPeekOn" in getroottable())) cornerPeekOn <- false; -if (!("camZoom" in getroottable())) camZoom <- 1.0; +persist layer = null +persist pf = null +persist fov = null +persist revealerId = -1 +persist path = null +persist hash = null +persist inv = null +persist bag = null +persist torchLight = null +persist cam = null +persist torchFx = null +persist sparkFx = null +persist level = 1 +persist seed = 42 +persist algo = "dungeon.bsp" +persist playerTx = 0 +persist playerTy = 0 +persist exitTx = 0 +persist exitTy = 0 +persist spawnTx = 0 +persist spawnTy = 0 +persist moveCd = 0.0 +persist status = "" +persist logLines = [] +persist loot = [] +persist uiBuilt = false +persist pathLen = 0 +persist visibleCount = 0 +persist exploredCount = 0 +persist flowPaths = [] +persist fovAlgo = "shadowcast" +persist torchRevealerId = -1 +persist mudCells = [] +persist fixturesLoaded = false +persist activeLootTable = "starter" +persist lastCatalogLevel = -1 +persist levelCfg = null +persist featureOn = {} +persist fovRadius = 6 +persist heroPerception = 0.0 +persist perceptionScale = 0.0 +persist torchFovRadius = 3 +persist cellCostValue = 8.0 +persist cellCostStrip = 4 +persist swarmStarts = 4 +persist levelIdIndex = 0 +persist facingDeg = 0.0 +persist facingHalf = 0.0 +persist cornerPeekOn = false +persist camZoom = 1.0 TILE_W <- 55.0; TILE_H <- 57.0; diff --git a/examples/hex-terrain/main.nut b/examples/hex-terrain/main.nut index 20d897f7..59616386 100644 --- a/examples/hex-terrain/main.nut +++ b/examples/hex-terrain/main.nut @@ -1,9 +1,9 @@ -if (!("hexWorld" in getroottable())) hexWorld <- null; -if (!("hexCamera" in getroottable())) hexCamera <- null; -if (!("hexYaw" in getroottable())) hexYaw <- 0.72; -if (!("hexPitch" in getroottable())) hexPitch <- 0.62; -if (!("hexSeed" in getroottable())) hexSeed <- 20260825; -if (!("hexTime" in getroottable())) hexTime <- 0.0; +persist hexWorld = null +persist hexCamera = null +persist hexYaw = 0.72 +persist hexPitch = 0.62 +persist hexSeed = 20260825 +persist hexTime = 0.0 function readText(path) { local f=file(path,"r"); if(f==null) return null; diff --git a/examples/housegen/main.nut b/examples/housegen/main.nut index eb944a73..345d64e4 100644 --- a/examples/housegen/main.nut +++ b/examples/housegen/main.nut @@ -1,6 +1,6 @@ // Runtime API smoke example. Replace model paths in the JSON with converted CC0 GLB assets. -if (!("housegen" in getroottable())) housegen <- null; -if (!("layout" in getroottable())) layout <- null; +persist housegen = null +persist layout = null eve_init = function() { housegen = eve.HouseGen(); diff --git a/examples/i18n/main.nut b/examples/i18n/main.nut index d3a0b698..ddcdf939 100644 --- a/examples/i18n/main.nut +++ b/examples/i18n/main.nut @@ -10,10 +10,10 @@ // 运行:make run/macosx-debug GAME=examples/i18n // ============================================================================ -if (!("i18n" in getroottable())) i18n <- eve.I18n(); -if (!("uiReady" in getroottable())) uiReady <- false; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("loaded" in getroottable())) loaded <- false; +persist i18n = eve.I18n() +persist uiReady = false +persist prevKeys = {} +persist loaded = false function edgePressed(key, down) { local was = (key in prevKeys) ? prevKeys[key] : false; diff --git a/examples/inspector-demo/main.nut b/examples/inspector-demo/main.nut index 4dcfc6ba..e8551ac9 100644 --- a/examples/inspector-demo/main.nut +++ b/examples/inspector-demo/main.nut @@ -4,8 +4,8 @@ // binds to the live `hero` instance two-way: widget edits write back to the // model, and sync() pulls external model changes into the view each frame. -if (!("hero" in getroottable())) hero <- null; -if (!("tick" in getroottable())) tick <- 0.0; +persist hero = null +persist tick = 0.0 class WeaponData { name = "Sword" diff --git a/examples/inventory/main.nut b/examples/inventory/main.nut index cf043441..751e7470 100644 --- a/examples/inventory/main.nut +++ b/examples/inventory/main.nut @@ -11,15 +11,15 @@ // 运行: make run/-debug GAME=examples/inventory // ============================================================================ -if (!("inv" in getroottable())) inv <- null; -if (!("bag" in getroottable())) bag <- null; -if (!("questBag" in getroottable())) questBag <- null; -if (!("stash" in getroottable())) stash <- null; -if (!("eq" in getroottable())) eq <- null; -if (!("logLines" in getroottable())) logLines <- []; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("lootLeft" in getroottable())) lootLeft <- 0; -if (!("uiBuilt" in getroottable())) uiBuilt <- false; +persist inv = null +persist bag = null +persist questBag = null +persist stash = null +persist eq = null +persist logLines = [] +persist prevKeys = {} +persist lootLeft = 0 +persist uiBuilt = false function pushLog(text) { logLines.push(text); diff --git a/examples/iso-grid-walk/main.nut b/examples/iso-grid-walk/main.nut index f735c50c..67c573db 100644 --- a/examples/iso-grid-walk/main.nut +++ b/examples/iso-grid-walk/main.nut @@ -1,22 +1,22 @@ // Developer-evaluation sample for independent 300x300 isometric PNG tiles. // Arrow keys / WASD move one logical cell. Click a cell to follow an A* path. -if (!("isoMap" in getroottable())) isoMap <- null; -if (!("logicLayer" in getroottable())) logicLayer <- null; -if (!("groundLayer" in getroottable())) groundLayer <- null; -if (!("routeLayer" in getroottable())) routeLayer <- null; -if (!("objectLayer" in getroottable())) objectLayer <- null; -if (!("pathfinder" in getroottable())) pathfinder <- null; -if (!("textures" in getroottable())) textures <- {}; -if (!("playerX" in getroottable())) playerX <- 1; -if (!("playerY" in getroottable())) playerY <- 1; -if (!("activePath" in getroottable())) activePath <- null; -if (!("pathIndex" in getroottable())) pathIndex <- 0; -if (!("stepClock" in getroottable())) stepClock <- 0.0; -if (!("statusText" in getroottable())) statusText <- "Ready"; -if (!("hudBuilt" in getroottable())) hudBuilt <- false; -if (!("previousKeys" in getroottable())) previousKeys <- {}; -if (!("previousMouseDown" in getroottable())) previousMouseDown <- false; +persist isoMap = null +persist logicLayer = null +persist groundLayer = null +persist routeLayer = null +persist objectLayer = null +persist pathfinder = null +persist textures = {} +persist playerX = 1 +persist playerY = 1 +persist activePath = null +persist pathIndex = 0 +persist stepClock = 0.0 +persist statusText = "Ready" +persist hudBuilt = false +persist previousKeys = {} +persist previousMouseDown = false const MAP_W = 4; const MAP_H = 4; diff --git a/examples/lattice-deform/main.nut b/examples/lattice-deform/main.nut index 9ef863f5..aad4ee4c 100644 --- a/examples/lattice-deform/main.nut +++ b/examples/lattice-deform/main.nut @@ -10,17 +10,17 @@ // 运行:make run/-debug GAME=examples/lattice-deform // ============================================================================ -if (!("anim" in getroottable())) anim <- null; -if (!("lattice" in getroottable())) lattice <- null; -if (!("sphereMesh" in getroottable())) sphereMesh <- null; -if (!("sphereObj" in getroottable())) sphereObj <- null; -if (!("cam" in getroottable())) cam <- null; -if (!("ground" in getroottable())) ground <- null; -if (!("geo" in getroottable())) geo <- null; -if (!("mode" in getroottable())) mode <- 1; -if (!("animT" in getroottable())) animT <- 0.0; -if (!("paused" in getroottable())) paused <- false; -if (!("squashTween" in getroottable())) squashTween <- null; +persist anim = null +persist lattice = null +persist sphereMesh = null +persist sphereObj = null +persist cam = null +persist ground = null +persist geo = null +persist mode = 1 +persist animT = 0.0 +persist paused = false +persist squashTween = null const PI = 3.14159265358979; diff --git a/examples/linear-structures/main.nut b/examples/linear-structures/main.nut index 2ebd7b5d..6a7743b4 100644 --- a/examples/linear-structures/main.nut +++ b/examples/linear-structures/main.nut @@ -4,15 +4,18 @@ // Textures are CC0 images bundled under assets/ (see ATTRIBUTION.md). // Run: make run/win32-debug GAME=examples/linear-structures -if (!("structures" in getroottable())) structures <- []; -if (!("active" in getroottable())) active <- 0; -if (!("segments" in getroottable())) segments <- 6; -if (!("segLength" in getroottable())) segLength <- 2.0; -if (!("autoSpin" in getroottable())) autoSpin <- true; -if (!("yaw" in getroottable())) yaw <- 0.0; -if (!("camYaw" in getroottable())) camYaw <- 0.0; -if (!("uiReady" in getroottable())) uiReady <- false; -if (!("rWasDown" in getroottable())) rWasDown <- false; +persist structures = [] +persist active = 0 +persist segments = 6 +persist segLength = 2.0 +persist autoSpin = true +persist yaw = 0.0 +persist camYaw = 0.0 +persist uiReady = false +persist rWasDown = false +persist tabWas = false +persist plusWas = false +persist minusWas = false local STRUCT_RECIPES = [ { id = "mesh.fence", file = "assets/wood.jpg", tex = "tex.soil", tint = { r=1.0, g=0.92, b=0.78 } }, @@ -175,18 +178,18 @@ eve_update = function(dt) { } local tabDown = keyboard.isDown("tab"); - local tabPressed = tabDown && !("tabWas" in getroottable() ? tabWas : false); - tabWas <- tabDown; + local tabPressed = tabDown && !tabWas; + tabWas = tabDown; if (tabPressed) { active = (active + 1) % STRUCT_RECIPES.len(); rebuild(); } local plus = keyboard.isDown("plus") || keyboard.isDown("equals"); - local wasPlus = ("plusWas" in getroottable() ? plusWas : false); - plusWas <- plus; + local wasPlus = plusWas; + plusWas = plus; if (plus && !wasPlus) { segments = segments + 1; rebuild(); } local minus = keyboard.isDown("minus"); - local wasMinus = ("minusWas" in getroottable() ? minusWas : false); - minusWas <- minus; + local wasMinus = minusWas; + minusWas = minus; if (minus && !wasMinus) { if (segments > 1) { segments = segments - 1; rebuild(); } } }; diff --git a/examples/metroidvania/main.nut b/examples/metroidvania/main.nut index bd177b5b..f9f1e668 100644 --- a/examples/metroidvania/main.nut +++ b/examples/metroidvania/main.nut @@ -8,7 +8,7 @@ const CAT_PLAYER_ATTACK = 16; const CAT_PICKUP = 32; const CAT_HAZARD = 64; -if (!("game" in getroottable())) game <- null; +persist game = null function absf(v) { return v < 0.0 ? -v : v; } diff --git a/examples/model-converter/main.nut b/examples/model-converter/main.nut index e7f2422c..c960d1cf 100644 --- a/examples/model-converter/main.nut +++ b/examples/model-converter/main.nut @@ -4,16 +4,16 @@ // into a stone / stone wall using Blender's Python package (bpy), then reloads // the produced .glb back into EVEngine with model3d to prove the pipeline. -if (!("mc" in getroottable())) mc <- null; -if (!("md" in getroottable())) md <- null; -if (!("seed" in getroottable())) seed <- 1847; -if (!("converter" in getroottable())) converter <- "stone"; -if (!("converterNames" in getroottable())) converterNames <- []; -if (!("converterIndex" in getroottable())) converterIndex <- 0; -if (!("status" in getroottable())) status <- "loading"; -if (!("uiReady" in getroottable())) uiReady <- false; -if (!("autoRun" in getroottable())) autoRun <- true; -if (!("ran" in getroottable())) ran <- false; +persist mc = null +persist md = null +persist seed = 1847 +persist converter = "stone" +persist converterNames = [] +persist converterIndex = 0 +persist status = "loading" +persist uiReady = false +persist autoRun = true +persist ran = false function repoRoot() { return "../.."; } function converterDir() { return repoRoot() + "/modelconverter/converters"; } diff --git a/examples/outline/main.nut b/examples/outline/main.nut index fa5cf910..78450892 100644 --- a/examples/outline/main.nut +++ b/examples/outline/main.nut @@ -10,27 +10,26 @@ // Controls: 1 toggle outline, [ / ] thickness, C cycle color. // --- Outline configuration ------------------------------------------------ -if (!("outlineColor" in getroottable())) outlineColor <- [0.05, 0.04, 0.07]; -if (!("outlineWidth" in getroottable())) outlineWidth <- 1.6; -if (!("outlineDepthThreshold" in getroottable())) outlineDepthThreshold <- 0.3; -if (!("outlineDepthSensitivity" in getroottable())) outlineDepthSensitivity <- 0.0; -if (!("outlineNormalThreshold" in getroottable())) outlineNormalThreshold <- 0.4; -if (!("outlineSoftness" in getroottable())) outlineSoftness <- 0.2; -if (!("outlineEnabled" in getroottable())) outlineEnabled <- true; +persist outlineColor = [0.05, 0.04, 0.07] +persist outlineWidth = 1.6 +persist outlineDepthThreshold = 0.3 +persist outlineDepthSensitivity = 0.0 +persist outlineNormalThreshold = 0.4 +persist outlineSoftness = 0.2 +persist outlineEnabled = true -if (!("OUTLINE_COLORS" in getroottable())) - OUTLINE_COLORS <- [ +persist OUTLINE_COLORS = [ [0.05, 0.04, 0.07], // ink [0.97, 0.95, 0.90], // paper [0.85, 0.10, 0.12], // red [0.10, 0.35, 0.85], // blue - ]; + ] // --- Persistent handles (kept across soft reloads) ------------------------- -if (!("olCube" in getroottable())) olCube <- null; -if (!("olCamera" in getroottable())) olCamera <- null; -if (!("olYaw" in getroottable())) olYaw <- 0.0; -if (!("olColorIdx" in getroottable())) olColorIdx <- 0; +persist olCube = null +persist olCamera = null +persist olYaw = 0.0 +persist olColorIdx = 0 function olPressed(k) { return key_just_pressed(k); } diff --git a/examples/particle-playback-lab/main.nut b/examples/particle-playback-lab/main.nut index caef00bb..ebe86594 100644 --- a/examples/particle-playback-lab/main.nut +++ b/examples/particle-playback-lab/main.nut @@ -1,6 +1,6 @@ -if (!("trailFx" in getroottable())) trailFx <- null; -if (!("burstFx" in getroottable())) burstFx <- null; -if (!("playbackTime" in getroottable())) playbackTime <- 0.0; +persist trailFx = null +persist burstFx = null +persist playbackTime = 0.0 eve_init = function() { gfx.setBackgroundColor(0.025, 0.035, 0.065, 1.0); diff --git a/examples/pcg-biome/README.md b/examples/pcg-biome/README.md new file mode 100644 index 00000000..5757f2af --- /dev/null +++ b/examples/pcg-biome/README.md @@ -0,0 +1,14 @@ +# PCG Biome + +对标 UE PCG Runtime/Hierarchical Generation 的端到端示例:空间数据组合、稳定点属性、 +多级 Cell 调度、生成/清理迟滞、方向视锥、时间预算,以及按 Cell 发布到 Scene 的实例批次。 + +```sh +make run/linux-debug GAME=examples/pcg-biome +``` + +- `W/A/S/D`:按格移动生成源,观察近景细节生成及远处 Cell 清理。 +- 大格生成树木,小格生成草和岩石;道路 Spline 作为排除空间。 +- 每个 Cell 使用独立 seed;失败请求可以用相同 seed 重试。 +- `BiomeRules` 负责优先级、密度和加权资产选择,PointGraph 负责可缓存的后处理。 +- `publishCellInstances` 把 PointSet 原子 reconcile 到独立 SceneHost,清理请求对应删除批次。 diff --git a/examples/pcg-biome/config.nut b/examples/pcg-biome/config.nut new file mode 100644 index 00000000..dc333ddd --- /dev/null +++ b/examples/pcg-biome/config.nut @@ -0,0 +1,6 @@ +config <- { + width = 960 + height = 640 + title = "EVEngine PCG Biome — Runtime Hierarchical Generation" + hotReload = true +}; diff --git a/examples/pcg-biome/main.nut b/examples/pcg-biome/main.nut new file mode 100644 index 00000000..ed030ed9 --- /dev/null +++ b/examples/pcg-biome/main.nut @@ -0,0 +1,127 @@ +// UE-PCG-style runtime biome: spatial composition -> point processing -> +// hierarchical cell scheduling -> reconciled scene instance batches. + +persist biomeRuntime = null +persist biomeCells = {} +persist sourceX = 320.0 +persist sourceZ = 220.0 +persist biomeStatus = "not started" +persist roadSpatial = null +persist coarseBiome = null +persist detailBiome = null + +gfx.setBackgroundColor(0.035, 0.055, 0.045, 1.0); + +function cellKey(request) { + return "L" + request.getLevel() + "/" + request.getX() + "/" + request.getZ(); +} + +function buildBiomeCell(request) { + local domain = procgen.boxVolume(request.getMinX(), 0.0, request.getMinZ(), + request.getMaxX(), 0.0, request.getMaxZ()); + local spacing = request.getLevel() == 0 ? 56.0 : 18.0; + local rules = request.getLevel() == 0 ? coarseBiome : detailBiome; + local attributed = rules.generate(domain, spacing, request.getSeed(), 0.72); + if (attributed == null) throw rules.getError(); + local graph = procgen.newPointGraph(); + graph.addNode("biome", "input"); + graph.setNodePoints("biome", attributed); + graph.addNode("prune", "self.prune"); + graph.setNodeFloat("prune", "radius", request.getLevel() == 0 ? 38.0 : 10.0); + graph.connect("biome", "prune"); + local result = graph.execute("prune"); + if (result == null) throw graph.getError(); + return result; +} + +function pumpRuntime() { + biomeRuntime.updateSource(sourceX, sourceZ, 1.0, 0.15); + biomeRuntime.beginFrame(); + for (local request = biomeRuntime.nextGenerate(); request != null; + request = biomeRuntime.nextGenerate()) { + try { + local points = buildBiomeCell(request); + if (!procgen.publishCellInstances("biome", request, points, "asset", "unknown")) + throw procgen.lastError(); + if (!biomeRuntime.completeGeneration(request, points)) + throw "stale cell generation request"; + biomeCells[cellKey(request)] <- points; + } catch (error) { + biomeRuntime.failGeneration(request); + print("pcg-biome generation failed: " + error + "\n"); + } + } + for (local request = biomeRuntime.nextCleanup(); request != null; + request = biomeRuntime.nextCleanup()) { + procgen.removeCellInstances("biome", request); + local key = cellKey(request); + if (key in biomeCells) delete biomeCells[key]; + biomeRuntime.completeCleanup(request); + } + biomeStatus = biomeRuntime.debugReport(); +} + +function resetBiome() { + foreach (key, points in biomeCells) procgen.removeInstances("biome/" + key); + biomeCells = {}; + biomeRuntime = procgen.newRuntimeGeneration(42017); + biomeRuntime.addLevel(256.0, 430.0, 1.35); + biomeRuntime.addLevel(64.0, 150.0, 1.65); + biomeRuntime.setDirectionWeight(0.3); + biomeRuntime.setFrustumCulling(true, 75.0, 96.0); + biomeRuntime.setMaxGenerating(3); + biomeRuntime.setFrameTimeBudget(3.0); + + local road = procgen.newPointSet(); + road.add(-100.0, 0.0, 80.0); + road.add(260.0, 0.0, 210.0); + road.add(680.0, 0.0, 260.0); + road.add(1100.0, 0.0, 500.0); + roadSpatial = procgen.splineData(road, 30.0); + + local world = procgen.boxVolume(-2048.0, 0.0, -2048.0, 2048.0, 0.0, 2048.0); + coarseBiome = procgen.newBiomeRules(); + coarseBiome.addLayer("forest", world, 10, 0.72); + coarseBiome.addAsset("forest", "oak", 4.0, 0.8, 1.2, true); + coarseBiome.addAsset("forest", "pine", 1.0, 0.9, 1.3, true); + coarseBiome.addExclusion(roadSpatial); + + detailBiome = procgen.newBiomeRules(); + detailBiome.addLayer("undergrowth", world, 10, 0.52); + detailBiome.addAsset("undergrowth", "grass", 3.0, 0.7, 1.15, true); + detailBiome.addAsset("undergrowth", "rock", 1.0, 0.65, 1.4, true); + detailBiome.addExclusion(roadSpatial); + pumpRuntime(); +} + +eve_init <- function() { resetBiome(); }; +eve_reload <- function() { resetBiome(); }; + +eve_update <- function(dt) { + local moved = false; + if (key_just_pressed("A")) { sourceX -= 96.0; moved = true; } + if (key_just_pressed("D")) { sourceX += 96.0; moved = true; } + if (key_just_pressed("W")) { sourceZ -= 96.0; moved = true; } + if (key_just_pressed("S")) { sourceZ += 96.0; moved = true; } + if (moved || biomeRuntime.getPendingGenerateCount() > 0) pumpRuntime(); +}; + +eve_render <- function() { + gfx.clear(); + local viewX = 90.0; + local viewZ = 80.0; + local scale = 0.55; + foreach (key, points in biomeCells) { + for (local i = 0; i < points.getCount(); ++i) { + local asset = points.getStringAttribute(i, "asset", ""); + local r = asset == "rock" ? 0.55 : (asset == "grass" ? 0.28 : 0.12); + local g = asset == "rock" ? 0.52 : (asset == "grass" ? 0.62 : 0.42); + local b = asset == "rock" ? 0.48 : (asset == "grass" ? 0.22 : 0.16); + local size = asset == "oak" || asset == "pine" ? 7.0 : 3.0; + gfx.drawSolidRect(viewX + points.getX(i) * scale, + viewZ + points.getZ(i) * scale, size, size, r, g, b, 1.0); + } + } + gfx.drawSolidRect(viewX + sourceX * scale - 5.0, viewZ + sourceZ * scale - 5.0, + 10.0, 10.0, 0.95, 0.72, 0.18, 1.0); +}; diff --git a/examples/procgen-script-pipeline/main.nut b/examples/procgen-script-pipeline/main.nut index d0ef187f..c1c7ec02 100644 --- a/examples/procgen-script-pipeline/main.nut +++ b/examples/procgen-script-pipeline/main.nut @@ -1,13 +1,13 @@ // Code-first procedural pipeline. Edit this file while the example is running: // eve_reload rebuilds a complete staging snapshot and atomically commits it. -if (!("forestSeed" in getroottable())) forestSeed <- 42; -if (!("forestPoints" in getroottable())) forestPoints <- null; -if (!("pipelineStatus" in getroottable())) pipelineStatus <- "not generated"; -if (!("debugStage" in getroottable())) debugStage <- "trees"; -if (!("debugPoints" in getroottable())) debugPoints <- null; -if (!("previousDebugPoints" in getroottable())) previousDebugPoints <- null; -if (!("roadPoints" in getroottable())) roadPoints <- null; +persist forestSeed = 42 +persist forestPoints = null +persist pipelineStatus = "not generated" +persist debugStage = "trees" +persist debugPoints = null +persist previousDebugPoints = null +persist roadPoints = null gfx.setBackgroundColor(0.055, 0.075, 0.07, 1.0); diff --git a/examples/procgen/main.nut b/examples/procgen/main.nut index 0b8a8738..563660a5 100644 --- a/examples/procgen/main.nut +++ b/examples/procgen/main.nut @@ -3,17 +3,17 @@ // Textures: T cycle recipe, N toggle normal preview, -/= change tex seed // Run: make run/linux-debug GAME=examples/procgen -if (!("layer" in getroottable())) layer <- null; -if (!("seed" in getroottable())) seed <- 42; -if (!("algo" in getroottable())) algo <- "dungeon.bsp"; -if (!("status" in getroottable())) status <- ""; -if (!("texRecipe" in getroottable())) texRecipe <- "tex.soil"; -if (!("texSeed" in getroottable())) texSeed <- 1; -if (!("tex" in getroottable())) tex <- null; -if (!("showNormal" in getroottable())) showNormal <- false; -if (!("texRecipes" in getroottable())) texRecipes <- ["tex.soil", "tex.stone", "tex.marble", "tex.water", "tex.sky_cloud"]; -if (!("texIndex" in getroottable())) texIndex <- 0; -if (!("wfcPreset" in getroottable())) wfcPreset <- "dungeon"; +persist layer = null +persist seed = 42 +persist algo = "dungeon.bsp" +persist status = "" +persist texRecipe = "tex.soil" +persist texSeed = 1 +persist tex = null +persist showNormal = false +persist texRecipes = ["tex.soil", "tex.stone", "tex.marble", "tex.water", "tex.sky_cloud"] +persist texIndex = 0 +persist wfcPreset = "dungeon" TILE <- 12.0; diff --git a/examples/rock-generator/main.nut b/examples/rock-generator/main.nut index 0d686dbf..e46002f6 100644 --- a/examples/rock-generator/main.nut +++ b/examples/rock-generator/main.nut @@ -1,20 +1,20 @@ // 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; +persist rock = null +persist camera = null +persist seed = 1847 +persist shapeIndex = 0 +persist shapeNames = ["mixed", "boulder", "slab", "block", "shard"] +persist flattening = 0.22 +persist angularity = 0.38 +persist erosion = 0.16 +persist noiseScale = 2.4 +persist roughness = 0.86 +persist autoSpin = true +persist yaw = 0.0 +persist uiReady = false +persist rWasDown = false function coreId() { local s = seed.tostring(); diff --git a/examples/roguelike-generator/main.nut b/examples/roguelike-generator/main.nut index 6e9a8162..cbaa8825 100644 --- a/examples/roguelike-generator/main.nut +++ b/examples/roguelike-generator/main.nut @@ -19,29 +19,29 @@ // Art: dungeon tileset is CC0 by "Buch" (OpenGameArt). The generated Grid2D // is translated into atlas GIDs and rendered by map.TileLayer. -if (!("gen" in getroottable())) gen <- null; -if (!("seed" in getroottable())) seed <- 20260815; -if (!("roomCount" in getroottable())) roomCount <- 9; -if (!("corridorStyle" in getroottable())) corridorStyle <- "l"; -if (!("floorPattern" in getroottable())) floorPattern <- "brick"; -if (!("decorSet" in getroottable())) decorSet <- "mixed"; -if (!("decorDensity" in getroottable())) decorDensity <- 0.10; -if (!("view3D" in getroottable())) view3D <- false; -if (!("wallHeight" in getroottable())) wallHeight <- 5.0; -if (!("showTiles" in getroottable())) showTiles <- false; -if (!("tileTex" in getroottable())) tileTex <- null; -if (!("groundTex" in getroottable())) groundTex <- null; -if (!("dungeonLayer" in getroottable())) dungeonLayer <- null; -if (!("decorLayer" in getroottable())) decorLayer <- null; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("info" in getroottable())) info <- ""; -if (!("objInfo" in getroottable())) objInfo <- ""; +persist gen = null +persist seed = 20260815 +persist roomCount = 9 +persist corridorStyle = "l" +persist floorPattern = "brick" +persist decorSet = "mixed" +persist decorDensity = 0.10 +persist view3D = false +persist wallHeight = 5.0 +persist showTiles = false +persist tileTex = null +persist groundTex = null +persist dungeonLayer = null +persist decorLayer = null +persist prevKeys = {} +persist info = "" +persist objInfo = "" -if (!("TILE" in getroottable())) TILE <- 16.0; -if (!("MAP_W" in getroottable())) MAP_W <- 26; -if (!("MAP_H" in getroottable())) MAP_H <- 22; -if (!("BASE_X" in getroottable())) BASE_X <- 300.0; -if (!("BASE_Y" in getroottable())) BASE_Y <- 112.0; +persist TILE = 16.0 +persist MAP_W = 26 +persist MAP_H = 22 +persist BASE_X = 300.0 +persist BASE_Y = 112.0 function pressed(name) { local slot = "key_" + name; diff --git a/examples/rpg/main.nut b/examples/rpg/main.nut index a7148465..ddb9fd70 100644 --- a/examples/rpg/main.nut +++ b/examples/rpg/main.nut @@ -22,14 +22,14 @@ // ---- 软热重载状态保护:脚本被 dofile 重跑时不清空已创建的实体/UI ---- // (约定见 example/main.nut) -if (!("rpg" in getroottable())) rpg <- null; -if (!("player" in getroottable())) player <- null; -if (!("enemy" in getroottable())) enemy <- null; -if (!("wave" in getroottable())) wave <- 1; -if (!("score" in getroottable())) score <- 0; -if (!("gameOver" in getroottable())) gameOver <- false; -if (!("battleLog" in getroottable())) battleLog <- []; -if (!("hitFlash" in getroottable())) hitFlash <- { player = 0.0, enemy = 0.0 }; +persist rpg = null +persist player = null +persist enemy = null +persist wave = 1 +persist score = 0 +persist gameOver = false +persist battleLog = [] +persist hitFlash = { player = 0.0, enemy = 0.0 } const PLAYER_MAX_HP = 100.0; const PLAYER_MAX_MP = 50.0; diff --git a/examples/skyscraper-generator/main.nut b/examples/skyscraper-generator/main.nut index 6c8fb01a..0d204ce5 100644 --- a/examples/skyscraper-generator/main.nut +++ b/examples/skyscraper-generator/main.nut @@ -1,20 +1,20 @@ // Sky Lab — interactive procedural skyscraper recipe showcase. // R regenerate, 1-4 fewer/more tiers, W window density, S spire toggle, T facade texture. -if (!("towerSeed" in getroottable())) towerSeed <- 2026; -if (!("tower" in getroottable())) tower <- null; -if (!("camera" in getroottable())) camera <- null; -if (!("tiers" in getroottable())) tiers <- 6; -if (!("windowCols" in getroottable())) windowCols <- 6; -if (!("windowRows" in getroottable())) windowRows <- 5; -if (!("spireHeight" in getroottable())) spireHeight <- 5.0; -if (!("setback" in getroottable())) setback <- 0.08; -if (!("facadeTex" in getroottable())) facadeTex <- null; -if (!("texName" in getroottable())) texName <- "tex.stone"; -if (!("texRecipes" in getroottable())) texRecipes <- ["tex.stone", "tex.marble", "tex.soil", "tex.water", "tex.sky_cloud"]; -if (!("texIndex" in getroottable())) texIndex <- 0; -if (!("yaw" in getroottable())) yaw <- 0.0; -if (!("prevKeys" in getroottable())) prevKeys <- {}; +persist towerSeed = 2026 +persist tower = null +persist camera = null +persist tiers = 6 +persist windowCols = 6 +persist windowRows = 5 +persist spireHeight = 5.0 +persist setback = 0.08 +persist facadeTex = null +persist texName = "tex.stone" +persist texRecipes = ["tex.stone", "tex.marble", "tex.soil", "tex.water", "tex.sky_cloud"] +persist texIndex = 0 +persist yaw = 0.0 +persist prevKeys = {} function pressed(k) { return key_just_pressed(k); diff --git a/examples/snow/main.nut b/examples/snow/main.nut index 3f5fcb4d..a2445874 100644 --- a/examples/snow/main.nut +++ b/examples/snow/main.nut @@ -20,25 +20,25 @@ // ============================================================================ // `snow` is the module slot bound by load.nut; `sf` is our SnowField instance. -if (!("sf" in getroottable())) sf <- null; -if (!("terrainHm" in getroottable())) terrainHm <- null; -if (!("combinedHm" in getroottable())) combinedHm <- null; -if (!("terrainMesh" in getroottable())) terrainMesh <- null; -if (!("terrainEnt" in getroottable())) terrainEnt <- null; -if (!("terrainTexA" in getroottable())) terrainTexA <- null; -if (!("terrainTexN" in getroottable())) terrainTexN <- null; -if (!("terrainTexH" in getroottable())) terrainTexH <- null; -if (!("walker" in getroottable())) walker <- null; -if (!("sun" in getroottable())) sun <- null; -if (!("cam" in getroottable())) cam <- null; -if (!("camAngle" in getroottable())) camAngle <- 0.0; -if (!("walkDemo" in getroottable())) walkDemo <- false; -if (!("snowfallOn" in getroottable())) snowfallOn <- false; -if (!("parallaxOn" in getroottable())) parallaxOn <- true; -if (!("walkAngle" in getroottable())) walkAngle <- 0.0; -if (!("stepTimer" in getroottable())) stepTimer <- 0.0; -if (!("rebuildTimer" in getroottable())) rebuildTimer <- 0.0; -if (!("helpPrinted" in getroottable())) helpPrinted <- false; +persist sf = null +persist terrainHm = null +persist combinedHm = null +persist terrainMesh = null +persist terrainEnt = null +persist terrainTexA = null +persist terrainTexN = null +persist terrainTexH = null +persist walker = null +persist sun = null +persist cam = null +persist camAngle = 0.0 +persist walkDemo = false +persist snowfallOn = false +persist parallaxOn = true +persist walkAngle = 0.0 +persist stepTimer = 0.0 +persist rebuildTimer = 0.0 +persist helpPrinted = false const W = 96; // heightmap cells const H = 96; diff --git a/examples/softbody/main.nut b/examples/softbody/main.nut index cf8419f5..f30e4995 100644 --- a/examples/softbody/main.nut +++ b/examples/softbody/main.nut @@ -9,12 +9,12 @@ // Run: make run/linux-debug GAME=examples/softbody // ============================================================================ -if (!("physics" in getroottable())) physics <- null; -if (!("cloth" in getroottable())) cloth <- null; -if (!("fluid" in getroottable())) fluid <- null; -if (!("grabbing" in getroottable())) grabbing <- false; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("windT" in getroottable())) windT <- 0.0; +persist physics = null +persist cloth = null +persist fluid = null +persist grabbing = false +persist prevKeys = {} +persist windT = 0.0 function edgePressed(key, down) { local was = (key in prevKeys) ? prevKeys[key] : false; diff --git a/examples/softbody3d/main.nut b/examples/softbody3d/main.nut index ade2016d..c0c1a5d1 100644 --- a/examples/softbody3d/main.nut +++ b/examples/softbody3d/main.nut @@ -15,16 +15,16 @@ // Run: make run/linux-debug GAME=examples/softbody3d // ============================================================================ -if (!("physics" in getroottable())) physics <- null; -if (!("world3" in getroottable())) world3 <- null; -if (!("cloth" in getroottable())) cloth <- null; -if (!("grabbing" in getroottable())) grabbing <- false; -if (!("windOn" in getroottable())) windOn <- true; -if (!("selfCollisionOn" in getroottable())) selfCollisionOn <- true; -if (!("foldOn" in getroottable())) foldOn <- true; -if (!("windT" in getroottable())) windT <- 0.0; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("prevMouse" in getroottable())) prevMouse <- false; +persist physics = null +persist world3 = null +persist cloth = null +persist grabbing = false +persist windOn = true +persist selfCollisionOn = true +persist foldOn = true +persist windT = 0.0 +persist prevKeys = {} +persist prevMouse = false const CLOTH_TOP_Y = 3.2; // pinned top row height (meters) const GRAB_HEIGHT = 2.0; // ray-plane height used for mouse grab diff --git a/examples/sprite-stack/main.nut b/examples/sprite-stack/main.nut index 6e33eafe..b660aa94 100644 --- a/examples/sprite-stack/main.nut +++ b/examples/sprite-stack/main.nut @@ -1,11 +1,11 @@ // Classic SpriteStack rendered entirely by the normal 2D pipeline. // A / D rotate, 1..4 switch primitive, Q / E change slice spacing. -if (!("angle" in getroottable())) angle <- 0.0; -if (!("kind" in getroottable())) kind <- "cylinder"; -if (!("spacing" in getroottable())) spacing <- 4.0; -if (!("stack" in getroottable())) stack <- null; -if (!("batch" in getroottable())) batch <- null; -if (!("copies" in getroottable())) copies <- []; +persist angle = 0.0 +persist kind = "cylinder" +persist spacing = 4.0 +persist stack = null +persist batch = null +persist copies = [] local tints = { cylinder = [0.32, 0.82, 0.68], sphere = [0.92, 0.52, 0.26], diff --git a/examples/tensor/main.nut b/examples/tensor/main.nut index 7924c48c..857ce436 100644 --- a/examples/tensor/main.nut +++ b/examples/tensor/main.nut @@ -8,12 +8,9 @@ // 3. speech-style attention step (fused SDPA) // 4. batched simulation update (broadcast arithmetic) -if (!("tf" in getroottable())) - tf <- null; -if (!("policy" in getroottable())) - policy <- null; -if (!("frame" in getroottable())) - frame <- 0; +persist tf = null +persist policy = null +persist frame = 0 eve_init = function() { gfx.setBackgroundColor(0.06, 0.08, 0.12, 1.0); diff --git a/examples/terrain-editor/main.nut b/examples/terrain-editor/main.nut index 9a5c073a..cf78892e 100644 --- a/examples/terrain-editor/main.nut +++ b/examples/terrain-editor/main.nut @@ -6,23 +6,23 @@ // // Run: make run/win32-debug GAME=examples/terrain-editor -if (!("hm" in getroottable())) hm <- null; -if (!("terrainMesh" in getroottable())) terrainMesh <- null; -if (!("terrainEnt" in getroottable())) terrainEnt <- null; -if (!("cam" in getroottable())) cam <- null; -if (!("vpCanvas" in getroottable())) vpCanvas <- null; -if (!("yaw" in getroottable())) yaw <- 0.75; -if (!("pitch" in getroottable())) pitch <- 0.45; -if (!("dist" in getroottable())) dist <- 26.0; -if (!("tool" in getroottable())) tool <- "raise"; -if (!("brushR" in getroottable())) brushR <- 4.0; -if (!("strength" in getroottable())) strength <- 0.06; -if (!("orbitDragging" in getroottable())) orbitDragging <- false; -if (!("orbitMouseX" in getroottable())) orbitMouseX <- 0.0; -if (!("orbitMouseY" in getroottable())) orbitMouseY <- 0.0; -if (!("editStatus" in getroottable())) editStatus <- "ready"; -if (!("meshDirty" in getroottable())) meshDirty <- false; -if (!("meshCooldown" in getroottable())) meshCooldown <- 0.0; +persist hm = null +persist terrainMesh = null +persist terrainEnt = null +persist cam = null +persist vpCanvas = null +persist yaw = 0.75 +persist pitch = 0.45 +persist dist = 26.0 +persist tool = "raise" +persist brushR = 4.0 +persist strength = 0.06 +persist orbitDragging = false +persist orbitMouseX = 0.0 +persist orbitMouseY = 0.0 +persist editStatus = "ready" +persist meshDirty = false +persist meshCooldown = 0.0 const W = 64; const H = 64; diff --git a/examples/tree-generator/main.nut b/examples/tree-generator/main.nut index 94dfb587..ab541c96 100644 --- a/examples/tree-generator/main.nut +++ b/examples/tree-generator/main.nut @@ -1,15 +1,15 @@ // 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; +persist treeSeed = 31415 +persist treeStyle = "lowpoly" +persist treeAlgorithm = "weberPenn" +persist leafMode = "cards" +persist leafDensity = 0.65 +persist treeMesh = null +persist treeObject = null +persist treeCamera = null +persist treeYaw = 0.0 function pressed(k) { return key_just_pressed(k); } diff --git a/examples/urban-generator/main.nut b/examples/urban-generator/main.nut index 4b161cc7..bcc703cd 100644 --- a/examples/urban-generator/main.nut +++ b/examples/urban-generator/main.nut @@ -18,25 +18,24 @@ // E toggle block extrusion (flat / 6 units) // L toggle parcel grid overlay (urban.parcels Grid2D preview) -if (!("urbanSeed" in getroottable())) urbanSeed <- 20260823; -if (!("landPreset" in getroottable())) landPreset <- "rect"; -if (!("streetPattern" in getroottable())) streetPattern <- "default"; -if (!("doOptimize" in getroottable())) doOptimize <- true; -if (!("targetParcels" in getroottable())) targetParcels <- 120; -if (!("minParcelArea" in getroottable())) minParcelArea <- 4.0; -if (!("extrude" in getroottable())) extrude <- 6.0; -if (!("cityMesh" in getroottable())) cityMesh <- null; -if (!("cityObject" in getroottable())) cityObject <- null; -if (!("cityCamera" in getroottable())) cityCamera <- null; -if (!("cityYaw" in getroottable())) cityYaw <- 0.0; -if (!("cityPitch" in getroottable())) cityPitch <- 0.62; -if (!("cityDist" in getroottable())) cityDist <- 150.0; -if (!("showGrid" in getroottable())) showGrid <- false; -if (!("gridLayer" in getroottable())) gridLayer <- null; -if (!("info" in getroottable())) info <- ""; -if (!("patterns" in getroottable())) - patterns <- ["default", "loop", "culdesac", "tree"]; -if (!("patternIndex" in getroottable())) patternIndex <- 0; +persist urbanSeed = 20260823 +persist landPreset = "rect" +persist streetPattern = "default" +persist doOptimize = true +persist targetParcels = 120 +persist minParcelArea = 4.0 +persist extrude = 6.0 +persist cityMesh = null +persist cityObject = null +persist cityCamera = null +persist cityYaw = 0.0 +persist cityPitch = 0.62 +persist cityDist = 150.0 +persist showGrid = false +persist gridLayer = null +persist info = "" +persist patterns = ["default", "loop", "culdesac", "tree"] +persist patternIndex = 0 function pressed(k) { return key_just_pressed(k); diff --git a/examples/vehicle/main.nut b/examples/vehicle/main.nut index 1f719bf9..ad57825c 100644 --- a/examples/vehicle/main.nut +++ b/examples/vehicle/main.nut @@ -17,30 +17,30 @@ // ============================================================================ // ---- 软热重载保护 ---- -if (!("vehicle" in getroottable())) vehicle <- null; -if (!("weapon" in getroottable())) weapon <- null; -if (!("physics" in getroottable())) physics <- null; -if (!("world" in getroottable())) world <- null; -if (!("rtsTank" in getroottable())) rtsTank <- null; -if (!("fpsCar" in getroottable())) fpsCar <- null; -if (!("enemyCar" in getroottable())) enemyCar <- null; -if (!("waypoint" in getroottable())) waypoint <- 0; -if (!("patrol" in getroottable())) patrol <- false; -if (!("enemySteer" in getroottable())) enemySteer <- 0.0; -if (!("enemySteerTimer" in getroottable())) enemySteerTimer <- 0.0; -if (!("carRespawn" in getroottable())) carRespawn <- -1.0; -if (!("enemyRespawn" in getroottable())) enemyRespawn <- -1.0; -if (!("eventLog" in getroottable())) eventLog <- []; -if (!("tracers" in getroottable())) tracers <- []; -if (!("prevKeys" in getroottable())) prevKeys <- {}; -if (!("prevMouse" in getroottable())) prevMouse <- { left = false, right = false }; -if (!("texTankBody" in getroottable())) texTankBody <- null; -if (!("texTankTurret" in getroottable())) texTankTurret <- null; -if (!("texCarBlue" in getroottable())) texCarBlue <- null; -if (!("texCarBlueTurret" in getroottable())) texCarBlueTurret <- null; -if (!("texCarRed" in getroottable())) texCarRed <- null; -if (!("texCarRedTurret" in getroottable())) texCarRedTurret <- null; -if (!("hudBuilt" in getroottable())) hudBuilt <- false; +persist vehicle = null +persist weapon = null +persist physics = null +persist world = null +persist rtsTank = null +persist fpsCar = null +persist enemyCar = null +persist waypoint = 0 +persist patrol = false +persist enemySteer = 0.0 +persist enemySteerTimer = 0.0 +persist carRespawn = -1.0 +persist enemyRespawn = -1.0 +persist eventLog = [] +persist tracers = [] +persist prevKeys = {} +persist prevMouse = { left = false, right = false } +persist texTankBody = null +persist texTankTurret = null +persist texCarBlue = null +persist texCarBlueTurret = null +persist texCarRed = null +persist texCarRedTurret = null +persist hudBuilt = false const PLAYER_DRIVER = 1; const PLAYER_GUNNER = 2; diff --git a/examples/voxel-terrain/main.nut b/examples/voxel-terrain/main.nut index f4f4e5a1..d77a2bce 100644 --- a/examples/voxel-terrain/main.nut +++ b/examples/voxel-terrain/main.nut @@ -18,35 +18,35 @@ // 运行:make run/win32-debug GAME=examples/voxel-terrain // ============================================================================ -if (!("math" in getroottable())) math <- eve.Math(); -if (!("voxel" in getroottable())) voxel <- null; -if (!("editor" in getroottable())) editor <- null; -if (!("world" in getroottable())) world <- null; -if (!("types" in getroottable())) types <- null; -if (!("atlas" in getroottable())) atlas <- null; -if (!("editSession" in getroottable())) editSession <- null; -if (!("voxelTarget" in getroottable())) voxelTarget <- null; -if (!("volumeFalloff" in getroottable())) volumeFalloff <- null; -if (!("volumeKernel" in getroottable())) volumeKernel <- null; -if (!("volumeOperation" in getroottable())) volumeOperation <- null; -if (!("volumeTool" in getroottable())) volumeTool <- null; -if (!("playerPos" in getroottable())) playerPos <- [0.0, 34.0, 64.0]; -if (!("yaw" in getroottable())) yaw <- PI; -if (!("pitch" in getroottable())) pitch <- -0.55; -if (!("elapsed" in getroottable())) elapsed <- 0.0; -if (!("presetIdx" in getroottable())) presetIdx <- 0; -if (!("seed" in getroottable())) seed <- 20260823; -if (!("streamRadius" in getroottable())) streamRadius <- 3; -if (!("mouseCaptured" in getroottable())) mouseCaptured <- false; -if (!("prevLeft" in getroottable())) prevLeft <- false; -if (!("prevRight" in getroottable())) prevRight <- false; -if (!("lastEdit" in getroottable())) lastEdit <- ""; -if (!("fpsAvg" in getroottable())) fpsAvg <- 60.0; -if (!("uiReady" in getroottable())) uiReady <- false; -if (!("shownHelp" in getroottable())) shownHelp <- false; -if (!("statsPrinted" in getroottable())) statsPrinted <- false; -if (!("shotPending" in getroottable())) shotPending <- true; // 首帧自动截一张便于冒烟验证 -if (!("shotPath" in getroottable())) shotPath <- "screenshot.png"; +persist math = eve.Math() +persist voxel = null +persist editor = null +persist world = null +persist types = null +persist atlas = null +persist editSession = null +persist voxelTarget = null +persist volumeFalloff = null +persist volumeKernel = null +persist volumeOperation = null +persist volumeTool = null +persist playerPos = [0.0, 34.0, 64.0] +persist yaw = PI +persist pitch = -0.55 +persist elapsed = 0.0 +persist presetIdx = 0 +persist seed = 20260823 +persist streamRadius = 3 +persist mouseCaptured = false +persist prevLeft = false +persist prevRight = false +persist lastEdit = "" +persist fpsAvg = 60.0 +persist uiReady = false +persist shownHelp = false +persist statsPrinted = false +persist shotPending = true // 首帧自动截一张便于冒烟验证 +persist shotPath = "screenshot.png" const TILE = 32; // atlas tile size in pixels const TILES_PER_ROW = 4; diff --git a/examples/voxel/main.nut b/examples/voxel/main.nut index b885c813..e28d39ae 100644 --- a/examples/voxel/main.nut +++ b/examples/voxel/main.nut @@ -9,16 +9,16 @@ // 运行:make run/-debug GAME=examples/voxel // ============================================================================ -if (!("math" in getroottable())) math <- eve.Math(); -if (!("voxel" in getroottable())) voxel <- null; -if (!("world" in getroottable())) world <- null; -if (!("types" in getroottable())) types <- null; -if (!("atlas" in getroottable())) atlas <- null; -if (!("cam" in getroottable())) cam <- null; -if (!("elapsed" in getroottable())) elapsed <- 0.0; -if (!("prevMouse" in getroottable())) prevMouse <- { left = false, right = false }; -if (!("shownHelp" in getroottable())) shownHelp <- false; -if (!("statsPrinted" in getroottable())) statsPrinted <- false; +persist math = eve.Math() +persist voxel = null +persist world = null +persist types = null +persist atlas = null +persist cam = null +persist elapsed = 0.0 +persist prevMouse = { left = false, right = false } +persist shownHelp = false +persist statsPrinted = false const TILE = 32; // atlas tile size in pixels const TILES_PER_ROW = 4; diff --git a/examples/waterfall-demo/main.nut b/examples/waterfall-demo/main.nut index c164d645..43b1cdae 100644 --- a/examples/waterfall-demo/main.nut +++ b/examples/waterfall-demo/main.nut @@ -3,11 +3,11 @@ // foam at the top lip + bottom splash pool. Press F to dump the current // frame to /tmp/waterfall_demo.png. -if (!("fall" in getroottable())) fall <- null; -if (!("fallEnt" in getroottable())) fallEnt <- null; -if (!("fallCam" in getroottable())) fallCam <- null; -if (!("fallTime" in getroottable())) fallTime <- 0.0; -if (!("fallSaved" in getroottable())) fallSaved <- false; +persist fall = null +persist fallEnt = null +persist fallCam = null +persist fallTime = 0.0 +persist fallSaved = false eve_init = function() { gfx.setBackgroundColor(0.03, 0.05, 0.08, 1.0); diff --git a/examples/weather-daynight/main.nut b/examples/weather-daynight/main.nut index c1f0db62..467cf20d 100644 --- a/examples/weather-daynight/main.nut +++ b/examples/weather-daynight/main.nut @@ -1,17 +1,17 @@ // LIVING SKY — unified DayNight + Weather + volumetric clouds / height fog. // 1..5 weather, Space pause, Left/Right scrub time, S lightning, C camera. -if (!("skyUiReady" in getroottable())) skyUiReady <- false; -if (!("skyProps" in getroottable())) skyProps <- []; -if (!("skyCamera" in getroottable())) skyCamera <- null; -if (!("skyClouds" in getroottable())) skyClouds <- null; -if (!("skyFog" in getroottable())) skyFog <- null; -if (!("skyWeather" in getroottable())) skyWeather <- "clear"; -if (!("skyCamAngle" in getroottable())) skyCamAngle <- 1.35; -if (!("skyAutoCamera" in getroottable())) skyAutoCamera <- true; -if (!("skyStrikeDown" in getroottable())) skyStrikeDown <- false; -if (!("skyTerrain" in getroottable())) skyTerrain <- null; -if (!("skyWaterfall" in getroottable())) skyWaterfall <- null; +persist skyUiReady = false +persist skyProps = [] +persist skyCamera = null +persist skyClouds = null +persist skyFog = null +persist skyWeather = "clear" +persist skyCamAngle = 1.35 +persist skyAutoCamera = true +persist skyStrikeDown = false +persist skyTerrain = null +persist skyWaterfall = null skyPresets <- { clear = { rain=0.0, wind=1.0, dir=15.0, cloud=0.18, cover=0.22, density=0.48, diff --git a/examples/weather/main.nut b/examples/weather/main.nut index bc87aac7..2b4faa7e 100644 --- a/examples/weather/main.nut +++ b/examples/weather/main.nut @@ -8,14 +8,14 @@ // 鈥?Sliders tune intensity + wind live // 鈥?Camera auto-orbits the diorama -if (!("uiReady" in getroottable())) uiReady <- false; -if (!("preset" in getroottable())) preset <- "clear"; -if (!("camAngle" in getroottable())) camAngle <- 0.0; -if (!("autoOrbit" in getroottable())) autoOrbit <- true; -if (!("autoCycle" in getroottable())) autoCycle <- false; -if (!("cycleTimer" in getroottable())) cycleTimer <- 0.0; -if (!("props" in getroottable())) props <- []; -if (!("strikeWasDown" in getroottable())) strikeWasDown <- false; +persist uiReady = false +persist preset = "clear" +persist camAngle = 0.0 +persist autoOrbit = true +persist autoCycle = false +persist cycleTimer = 0.0 +persist props = [] +persist strikeWasDown = false presetOrder <- ["clear", "drizzle", "rain", "storm", "snow", "fog"]; diff --git a/platform/webgpu/game-shell/main.nut b/platform/webgpu/game-shell/main.nut index da5dfcc4..4009a35f 100644 --- a/platform/webgpu/game-shell/main.nut +++ b/platform/webgpu/game-shell/main.nut @@ -7,7 +7,7 @@ // - 点“重置状态”会清空 __pg 并重建场景。 // 木纹贴图让旋转一目了然——试试改成 spinSpeed = 2.5 看转速变化。 -if (!("__pg" in getroottable())) __pg <- {}; +persist __pg = {} // 顶层初始化:每次 dofile(应用/重置)都会执行,保证 eve_update 在任何时刻 // 都能安全访问状态,不依赖 eve_init 是否已运行。 if (!("frames" in __pg)) __pg.frames <- 0; diff --git a/platform/webgpu/game-shell/playground/demos/2d_sprites.nut b/platform/webgpu/game-shell/playground/demos/2d_sprites.nut index ad0087d5..f342a35d 100644 --- a/platform/webgpu/game-shell/playground/demos/2d_sprites.nut +++ b/platform/webgpu/game-shell/playground/demos/2d_sprites.nut @@ -3,7 +3,7 @@ // 使用打包在 /game/playground/assets 下的 CC0 贴图(Kenney Platformer Pack)。 // 方向键控制方块移动;修改下面的 spd 后按 Ctrl+Enter 立即生效。 -if (!("__pg" in getroottable())) __pg <- {}; +persist __pg = {} // 顶层初始化:每次 dofile(应用/重置)都会执行,保证 eve_update 在任何时刻 // 都能安全访问状态,不依赖 eve_init 是否已运行。 if (!("frames" in __pg)) __pg.frames <- 0; diff --git a/platform/webgpu/game-shell/playground/demos/3d_sphere.nut b/platform/webgpu/game-shell/playground/demos/3d_sphere.nut index 4edabb94..73fb5770 100644 --- a/platform/webgpu/game-shell/playground/demos/3d_sphere.nut +++ b/platform/webgpu/game-shell/playground/demos/3d_sphere.nut @@ -7,7 +7,7 @@ // - 点“重置状态”会清空 __pg 并重建场景。 // 木纹贴图让旋转一目了然——试试改成 spinSpeed = 2.5 看转速变化。 -if (!("__pg" in getroottable())) __pg <- {}; +persist __pg = {} // 顶层初始化:每次 dofile(应用/重置)都会执行,保证 eve_update 在任何时刻 // 都能安全访问状态,不依赖 eve_init 是否已运行。 if (!("frames" in __pg)) __pg.frames <- 0; diff --git a/platform/webgpu/game-shell/playground/demos/3d_textured.nut b/platform/webgpu/game-shell/playground/demos/3d_textured.nut index 1c1dc681..298e2ed9 100644 --- a/platform/webgpu/game-shell/playground/demos/3d_textured.nut +++ b/platform/webgpu/game-shell/playground/demos/3d_textured.nut @@ -3,7 +3,7 @@ // 立方体贴 wood.jpg(CC0,Wikimedia Commons),金属球绕它公转。 // 修改 cubeSpeed / sphereRadius 后按 Ctrl+Enter 立即生效。 -if (!("__pg" in getroottable())) __pg <- {}; +persist __pg = {} // 顶层初始化:每次 dofile(应用/重置)都会执行,保证 eve_update 在任何时刻 // 都能安全访问状态,不依赖 eve_init 是否已运行。 if (!("frames" in __pg)) __pg.frames <- 0; diff --git a/platform/webgpu/game-shell/playground/demos/hot_state.nut b/platform/webgpu/game-shell/playground/demos/hot_state.nut index 003a108e..0770f6e1 100644 --- a/platform/webgpu/game-shell/playground/demos/hot_state.nut +++ b/platform/webgpu/game-shell/playground/demos/hot_state.nut @@ -5,7 +5,7 @@ // 2. 观察控制台:每次应用都会打印累计 frames,状态没有重置; // 3. 点“重置状态”清空 __pg,立方体从 0 帧重新开始。 -if (!("__pg" in getroottable())) __pg <- {}; +persist __pg = {} // 顶层初始化:每次 dofile(应用/重置)都会执行,保证 eve_update 在任何时刻 // 都能安全访问状态,不依赖 eve_init 是否已运行。 if (!("frames" in __pg)) __pg.frames <- 0; diff --git a/scripts/generate_binding_contracts.py b/scripts/generate_binding_contracts.py new file mode 100644 index 00000000..91faff9b --- /dev/null +++ b/scripts/generate_binding_contracts.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +"""Generate EveScript Binding Contracts from SimpleSquirrel addFunc declarations. + +The generator reads the same C++ declarations that bind functions through +SimpleSquirrel. Member-function parameter names come from their class +declarations; lambda parameters come from the binding expression itself. A +binding that cannot supply real parameter names is rejected instead of +silently publishing arg0/arg1 placeholders. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, field +import json +from pathlib import Path +import re +import sys + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOTS = (ROOT / "src" / "engine", ROOT / "src" / "modules") +SOURCE_SUFFIXES = {".h", ".hpp", ".cpp"} + + +@dataclass +class Parameter: + name: str + cpp_type: str + script_type: str + nullable: bool = False + default: str = "" + unit: str = "None" + choices: list[str] = field(default_factory=list) + + +@dataclass +class Contract: + module: str + script_class: str + method: str + parameters: list[Parameter] + return_type: str + return_nullable: bool + ownership: str + thread_affinity: str + platforms: list[str] + documentation_id: str + source: str + + @property + def key(self) -> str: + owner = f"{self.script_class}." if self.script_class else "" + return f"{self.module}/{owner}{self.method}" + + +def mask_comments(source: str) -> str: + output = list(source) + quote: str | None = None + line_comment = False + block_comment = False + i = 0 + while i < len(source): + value = source[i] + nxt = source[i + 1] if i + 1 < len(source) else "" + if line_comment: + if value == "\n": + line_comment = False + else: + output[i] = " " + elif block_comment: + if value == "*" and nxt == "/": + output[i] = output[i + 1] = " " + i += 1 + block_comment = False + elif value != "\n": + output[i] = " " + elif quote: + if value == "\\": + output[i] = " " + if i + 1 < len(output): + output[i + 1] = " " + i += 1 + elif value == quote: + quote = None + elif value in {'"', "'"}: + quote = value + elif value == "/" and nxt == "/": + output[i] = output[i + 1] = " " + i += 1 + line_comment = True + elif value == "/" and nxt == "*": + output[i] = output[i + 1] = " " + i += 1 + block_comment = True + i += 1 + return "".join(output) + + +def matching(source: str, opening: int, left: str = "(", right: str = ")") -> int | None: + depth = 0 + quote: str | None = None + i = opening + while i < len(source): + value = source[i] + nxt = source[i + 1] if i + 1 < len(source) else "" + if quote: + if value == "\\": + i += 2 + continue + if value == quote: + quote = None + elif value in {'"', "'"}: + quote = value + elif value == "/" and nxt == "/": + newline = source.find("\n", i + 2) + i = len(source) if newline < 0 else newline + continue + elif value == "/" and nxt == "*": + end = source.find("*/", i + 2) + i = len(source) if end < 0 else end + 2 + continue + elif value == left: + depth += 1 + elif value == right: + depth -= 1 + if depth == 0: + return i + i += 1 + return None + + +def split_top_level(value: str, delimiter: str = ",") -> list[str]: + result: list[str] = [] + begin = 0 + depths = {"(": 0, "[": 0, "{": 0, "<": 0} + pairs = {")": "(", "]": "[", "}": "{", ">": "<"} + quote: str | None = None + i = 0 + while i < len(value): + current = value[i] + if quote: + if current == "\\": + i += 2 + continue + if current == quote: + quote = None + elif current in {'"', "'"}: + quote = current + elif current in depths: + depths[current] += 1 + elif current in pairs: + depths[pairs[current]] = max(0, depths[pairs[current]] - 1) + elif current == delimiter and not any(depths.values()): + result.append(value[begin:i].strip()) + begin = i + 1 + i += 1 + result.append(value[begin:].strip()) + return [item for item in result if item] + + +def script_type(cpp_type: str) -> tuple[str, bool]: + normalized = re.sub(r"\b(const|volatile|class|struct)\b", "", cpp_type) + normalized = re.sub(r"\s+", " ", normalized).strip() + nullable = "*" in normalized or "optional<" in normalized + plain = normalized.replace("&", "").replace("*", "").strip() + plain = re.sub(r"^(std::|ssq::|eve::)", "", plain) + if plain in {"void"}: + return "void", False + if plain in {"bool", "SQBool"}: + return "bool", nullable + if re.search(r"(^|::)(u?int\d*_t|size_t|int|long|short|unsigned|SQInteger)$", plain): + return "int", nullable + if plain in {"float", "double", "SQFloat"}: + return "float", nullable + if "string" in plain or plain in {"char", "SQChar"}: + return "string", nullable + if re.search(r"(^|::)(vector|array|Array)<", plain) or plain == "Array": + return "Array", nullable + if re.search(r"(^|::)(map|unordered_map|Table)<", plain) or plain == "Table": + return "Table", nullable + if plain in {"Object", "Function", "Class", "Instance"}: + return "dynamic", nullable + if re.fullmatch(r"[TUVW]", plain): + return "dynamic", nullable + match = re.search(r"([A-Za-z_]\w*)\s*(?:<.*>)?$", plain) + return (match.group(1) if match else "dynamic"), nullable + + +def parse_parameter(text: str, index: int) -> Parameter | None: + text = re.sub(r"\[\[[^]]*\]\]", "", text).strip() + if not text or text == "void": + return None + pieces = split_top_level(text, "=") + declaration = pieces[0].strip() + # A C++ default is not automatically a script default: the native wrapper + # still expects its full positional arity unless binding metadata says + # otherwise. Keep it out of the generated language contract. + declaration = re.sub(r"\s*\.\.\.\s*$", "", declaration) + name_match = re.search(r"([A-Za-z_]\w*)\s*(?:\[.*\])?$", declaration) + if not name_match: + return Parameter(f"arg{index}", declaration, script_type(declaration)[0]) + name = name_match.group(1) + cpp_type = declaration[: name_match.start()].strip() + if not cpp_type: + return Parameter(f"arg{index}", declaration, script_type(declaration)[0]) + mapped, nullable = script_type(cpp_type) + return Parameter(name, cpp_type, mapped, nullable) + + +def parse_parameters(text: str) -> list[Parameter]: + result: list[Parameter] = [] + for index, item in enumerate(split_top_level(text)): + parameter = parse_parameter(item, index) + if parameter: + result.append(parameter) + return result + + +def class_blocks(source: str) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + masked = mask_comments(source) + pattern = re.compile(r"\b(?:class|struct)\s+([A-Za-z_]\w*)[^;{]*\{") + for match in pattern.finditer(masked): + opening = masked.find("{", match.start()) + closing = matching(masked, opening, "{", "}") + if closing is not None: + result.setdefault(match.group(1), []).append(source[opening + 1 : closing]) + return result + + +class SignatureIndex: + def __init__(self, sources: dict[Path, str]) -> None: + self.sources = sources + self.masked = {path: mask_comments(source) for path, source in sources.items()} + self.classes: dict[str, list[str]] = {} + self.aliases: dict[str, str] = {} + for source in sources.values(): + for name, blocks in class_blocks(source).items(): + self.classes.setdefault(name, []).extend(blocks) + for match in re.finditer( + r"\busing\s+([A-Za-z_]\w*)\s*=\s*(?:[A-Za-z_]\w*::)*([A-Za-z_]\w*)\s*(?:<[^;]+>)?\s*;", + mask_comments(source), + ): + self.aliases[match.group(1)] = match.group(2) + for concrete_class in self.classes: + self.aliases.pop(concrete_class, None) + self.member_cache: dict[tuple[str, str], tuple[str, list[Parameter]] | None] = {} + self.free_cache: dict[str, tuple[str, list[Parameter]] | None] = {} + + def member(self, qualified: str, method: str) -> tuple[str, list[Parameter]] | None: + class_name = qualified.split("::")[-1] + cache_key = (class_name, method) + if cache_key in self.member_cache: + return self.member_cache[cache_key] + lookup_class = self.aliases.get(class_name, class_name) + candidates: list[tuple[str, list[Parameter]]] = [] + declaration_pattern = re.compile( + rf"(?m)(?:^|[;{{}}])\s*(?:virtual\s+|static\s+|inline\s+|constexpr\s+|explicit\s+)*" + rf"([A-Za-z_~][\w:\s<>,*&]*?)\b{re.escape(method)}\s*\(") + for block in self.classes.get(lookup_class, []): + masked = mask_comments(block) + for match in declaration_pattern.finditer(masked): + opening = masked.find("(", match.start()) + closing = matching(block, opening) + if closing is None: + continue + candidates.append((match.group(1).strip().splitlines()[-1].strip(), + parse_parameters(block[opening + 1 : closing]))) + if not candidates: + definition_pattern = re.compile( + rf"(?m)([A-Za-z_~][\w:\s<>,*&]*?)\b{re.escape(lookup_class)}::{re.escape(method)}\s*\(") + needle = f"{lookup_class}::{method}" + for path, source in self.sources.items(): + if needle not in source: + continue + for match in definition_pattern.finditer(self.masked[path]): + opening = self.masked[path].find("(", match.start()) + closing = matching(source, opening) + if closing is None: + continue + candidates.append((match.group(1).strip().splitlines()[-1].strip(), + parse_parameters(source[opening + 1 : closing]))) + if not candidates and method == "getName": + candidates.append(("std::string", [])) + if not candidates: + inherited: list[tuple[str, list[Parameter]]] = [] + for blocks in self.classes.values(): + for block in blocks: + masked = mask_comments(block) + for match in declaration_pattern.finditer(masked): + opening = masked.find("(", match.start()) + closing = matching(block, opening) + if closing is not None: + inherited.append((match.group(1).strip().splitlines()[-1].strip(), + parse_parameters(block[opening + 1 : closing]))) + shapes = { + (return_type, tuple((parameter.name, parameter.cpp_type) for parameter in parameters)) + for return_type, parameters in inherited + } + if len(shapes) == 1 and inherited: + candidates.append(inherited[0]) + if not candidates: + self.member_cache[cache_key] = None + return None + candidates.sort(key=lambda item: (sum(parameter.name.startswith("arg") for parameter in item[1]), + -len(item[1]))) + self.member_cache[cache_key] = candidates[0] + return candidates[0] + + def free(self, name: str) -> tuple[str, list[Parameter]] | None: + if name in self.free_cache: + return self.free_cache[name] + pattern = re.compile(rf"(?m)^\s*(?:static\s+)?([A-Za-z_][\w:\s<>,*&]*?)\b{re.escape(name)}\s*\(") + candidates = [] + needle = f"{name}(" + for path, source in self.sources.items(): + if needle not in source and f"{name} (" not in source: + continue + for match in pattern.finditer(self.masked[path]): + opening = self.masked[path].find("(", match.start()) + closing = matching(source, opening) + if closing is not None: + candidates.append((match.group(1).strip(), parse_parameters(source[opening + 1 : closing]))) + self.free_cache[name] = candidates[0] if candidates else None + return self.free_cache[name] + + +def module_for(path: Path) -> str: + relative = path.relative_to(ROOT).parts + if len(relative) >= 3 and relative[:2] == ("src", "modules"): + return relative[2] + if len(relative) >= 3 and relative[:2] == ("src", "engine"): + return relative[2] + return "engine" + + +def class_bindings(source: str) -> list[tuple[int, str, str]]: + pattern = re.compile( + r"\b(?:auto|ssq::Class)\s+([A-Za-z_]\w*)\s*=\s*[A-Za-z_]\w*\.addClass" + r"(?:<\s*([A-Za-z_:]\w*(?:::\w+)*)[^>]*>)?\s*\(") + result = [] + for match in pattern.finditer(mask_comments(source)): + opening = source.find("(", match.start()) + closing = matching(source, opening) + if closing is None: + continue + arguments = split_top_level(source[opening + 1 : closing]) + literal = re.match(r'\s*"([^"]+)"', arguments[0]) if arguments else None + result.append((match.start(), match.group(1), literal.group(1) if literal else (match.group(2) or ""))) + return result + + +def callable_signature(expression: str, signatures: SignatureIndex) -> tuple[str, str, list[Parameter]] | None: + member = re.search(r"&\s*([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)::([A-Za-z_]\w*)", expression) + if member: + signature = signatures.member(member.group(1), member.group(2)) + return (member.group(1).split("::")[-1], *signature) if signature else None + + lambda_match = re.search(r"\[[^]]*\]\s*\(", expression) + if lambda_match: + opening = expression.find("(", lambda_match.start()) + closing = matching(expression, opening) + if closing is None: + return None + tail = expression[closing + 1 :] + result = re.match(r"\s*(?:mutable\s*)?(?:noexcept\s*)?(?:->\s*([^\s{]+(?:\s*[*&])?))?", tail) + return "", (result.group(1).strip() if result and result.group(1) else "dynamic"), parse_parameters( + expression[opening + 1 : closing] + ) + + function = re.search(r"(?:std::function\s*<[^>]+>\s*\()?\s*([A-Za-z_]\w*)\s*\)?\s*$", expression) + if function: + signature = signatures.free(function.group(1)) + return ("", *signature) if signature else None + return None + + +def extract_contracts(sources: dict[Path, str], enabled_modules: set[str] | None = None, + platform: str = "") -> tuple[list[Contract], list[str]]: + signatures = SignatureIndex(sources) + contracts: dict[str, Contract] = {} + unresolved: list[str] = [] + call_pattern = re.compile(r"\b([A-Za-z_]\w*)\.addFunc\s*\(") + for path, source in sources.items(): + relative = path.relative_to(ROOT).parts + if (enabled_modules is not None and len(relative) >= 3 and relative[:2] == ("src", "modules") + and relative[2] not in enabled_modules): + continue + bindings = class_bindings(source) + masked = mask_comments(source) + for match in call_pattern.finditer(masked): + opening = source.find("(", match.start()) + closing = matching(source, opening) + if closing is None: + unresolved.append(f"{path.relative_to(ROOT)}:{source.count(chr(10), 0, match.start()) + 1}: unclosed addFunc") + continue + arguments = split_top_level(source[opening + 1 : closing]) + method_match = re.match(r'\s*"([^"]+)"', arguments[0]) if arguments else None + if not method_match or len(arguments) < 2: + unresolved.append(f"{path.relative_to(ROOT)}:{source.count(chr(10), 0, match.start()) + 1}: dynamic addFunc") + continue + method = method_match.group(1) + expression = ",".join(arguments[1:]) + signature = callable_signature(expression, signatures) + if signature is None: + unresolved.append( + f"{path.relative_to(ROOT)}:{source.count(chr(10), 0, match.start()) + 1}: {match.group(1)}.{method}" + ) + continue + cpp_class, return_cpp, parameters = signature + script_class = "" + for position, receiver, bound_class in reversed(bindings): + if position < match.start() and receiver == match.group(1): + script_class = bound_class + break + if not script_class: + script_class = cpp_class if match.group(1) not in {"table", "eve", "root", "vm"} else "" + if (parameters and cpp_class == "" and "*" in parameters[0].cpp_type + and match.group(1) not in {"table", "eve", "root", "vm"}): + parameters = parameters[1:] + mapped_return, return_nullable = script_type(return_cpp) + ownership = "borrowed" if "*" in return_cpp else "value" + module = module_for(path) + contract = Contract(module, script_class, method, parameters, mapped_return, return_nullable, ownership, + "main", [platform] if platform else [], + f"{module}.{script_class + '.' if script_class else ''}{method}", + f"{path.relative_to(ROOT).as_posix()}:{source.count(chr(10), 0, match.start()) + 1}") + existing = contracts.get(contract.key) + if existing is None or sum(p.name.startswith("arg") for p in contract.parameters) < sum( + p.name.startswith("arg") for p in existing.parameters + ): + contracts[contract.key] = contract + return sorted(contracts.values(), key=lambda item: item.key), unresolved + + +def cpp_string(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def write_cpp(path: Path, contracts: list[Contract]) -> None: + lines = [ + '// Generated by scripts/generate_binding_contracts.py; do not edit.', + '#include "common/BindingContracts.h"', + '#include "common/ScriptCompiler.h"', + '', + '#include ', + '', + 'namespace eve::script {', + 'namespace {', + 'struct ParameterSeed {', + ' const char* name;', + ' const char* type;', + ' bool nullable;', + ' const char* scriptDefault;', + ' ScriptUnit unit;', + ' std::initializer_list choices;', + '};', + '', + 'void addContract(BindingContractRegistry& registry, const char* module, const char* scriptClass,', + ' const char* method, const char* returnType, bool returnNullable, const char* ownership,', + ' const char* threadAffinity, std::initializer_list platforms,', + ' const char* documentationId, std::initializer_list parameters) {', + ' BindingContract contract;', + ' contract.module = module;', + ' contract.scriptClass = scriptClass;', + ' contract.method = method;', + ' contract.returnType = returnType;', + ' contract.returnNullable = returnNullable;', + ' contract.ownership = ownership;', + ' contract.threadAffinity = threadAffinity;', + ' for (const char* platform : platforms) contract.platforms.emplace_back(platform);', + ' contract.documentationId = documentationId;', + ' for (const ParameterSeed& seed : parameters) {', + ' BindingParameterContract parameter;', + ' parameter.name = seed.name;', + ' parameter.type = seed.type;', + ' parameter.nullable = seed.nullable;', + ' if (seed.scriptDefault != nullptr) parameter.scriptDefault = seed.scriptDefault;', + ' parameter.unit = seed.unit;', + ' for (const char* choice : seed.choices) parameter.choices.emplace_back(choice);', + ' contract.parameters.push_back(std::move(parameter));', + ' }', + ' registry.registerContract(std::move(contract));', + '}', + ] + calls: list[str] = [] + for contract in contracts: + parameters = [] + for parameter in contract.parameters: + default = cpp_string(parameter.default) if parameter.default else 'nullptr' + choices = "{" + ", ".join(cpp_string(choice) for choice in parameter.choices) + "}" + parameters.append( + f'{{{cpp_string(parameter.name)}, {cpp_string(parameter.script_type)}, ' + f'{str(parameter.nullable).lower()}, {default}, ScriptUnit::{parameter.unit}, {choices}}}' + ) + calls.append( + f' addContract(registry, {cpp_string(contract.module)}, {cpp_string(contract.script_class)}, ' + f'{cpp_string(contract.method)}, {cpp_string(contract.return_type)}, ' + f'{str(contract.return_nullable).lower()}, {cpp_string(contract.ownership)}, ' + f'{cpp_string(contract.thread_affinity)}, ' + f'{{{", ".join(cpp_string(platform) for platform in contract.platforms)}}}, ' + f'{cpp_string(contract.documentation_id)}, {{{", ".join(parameters)}}});' + ) + chunk_size = 128 + chunk_count = (len(calls) + chunk_size - 1) // chunk_size + for index in range(chunk_count): + lines += [ + '', + '#if defined(_MSC_VER)', + '__declspec(noinline)', + '#elif defined(__GNUC__) || defined(__clang__)', + '__attribute__((noinline))', + '#endif', + f'void registerChunk{index}(BindingContractRegistry& registry) {{', + *calls[index * chunk_size : (index + 1) * chunk_size], + '}', + ] + lines += ['} // namespace', '', 'void registerEngineBindingContracts(BindingContractRegistry& registry) {'] + for index in range(chunk_count): + lines.append(f' registerChunk{index}(registry);') + lines += ['}', '', '} // namespace eve::script', ''] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def source_files() -> dict[Path, str]: + result = {} + for root in SOURCE_ROOTS: + for path in root.rglob("*"): + if path.suffix in SOURCE_SUFFIXES and ".generated." not in path.name: + result[path] = path.read_text(encoding="utf-8", errors="replace") + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path) + parser.add_argument("--allow-unresolved", action="store_true") + parser.add_argument("--report", type=Path) + parser.add_argument("--modules", help="Comma-separated enabled src/modules directories; engine bindings are always included") + parser.add_argument("--platform", default="", help="Platform availability recorded on generated contracts") + args = parser.parse_args() + + enabled_modules = ({item for item in args.modules.split(",") if item} if args.modules is not None else None) + contracts, unresolved = extract_contracts(source_files(), enabled_modules, args.platform) + placeholder_count = sum(parameter.name.startswith("arg") for contract in contracts for parameter in contract.parameters) + print(f"Binding Contract: {len(contracts)} contracts, {len(unresolved)} unresolved bindings, " + f"{placeholder_count} placeholder parameter names") + placeholders = [ + f"{contract.source}: {contract.key}: " + ", ".join( + f"{parameter.name}<{parameter.cpp_type}>" for parameter in contract.parameters if parameter.name.startswith("arg") + ) + for contract in contracts + if any(parameter.name.startswith("arg") for parameter in contract.parameters) + ] + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + report = [*(f"UNRESOLVED {item}" for item in unresolved), *(f"PLACEHOLDER {item}" for item in placeholders)] + args.report.write_text("\n".join(report) + ("\n" if report else ""), encoding="utf-8") + if args.output: + write_cpp(args.output, contracts) + if (unresolved or placeholder_count) and not args.allow_unresolved: + for item in unresolved[:50]: + print(f"UNRESOLVED {item}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/migrate_evescript.py b/scripts/migrate_evescript.py new file mode 100644 index 00000000..c8d067da --- /dev/null +++ b/scripts/migrate_evescript.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Migrate repository game scripts to EveScript v1 declarations. + +The migration is deliberately syntax-aware enough to preserve nested table, +array, call, string and comment contents. It only rewrites root-scope forms +whose EveScript lowering is exactly equivalent: + + if (!("score" in getroottable())) score <- 0; + score <- persist("score", function() { return 0; }); + +Both become: + + persist score = 0 + +Run without --write to audit; --check fails when migratable legacy forms remain. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import re +import sys + + +EXCLUDED_DIRS = {".git", "build", "third-party"} + + +@dataclass(frozen=True) +class Edit: + start: int + end: int + replacement: str + kind: str + name: str + + +def _masked(source: str) -> str: + """Return source with comments/strings blanked while preserving newlines.""" + out = list(source) + quote: str | None = None + line_comment = False + block_comment = False + i = 0 + while i < len(out): + ch = source[i] + nxt = source[i + 1] if i + 1 < len(source) else "" + if line_comment: + if ch == "\n": + line_comment = False + else: + out[i] = " " + elif block_comment: + if ch == "*" and nxt == "/": + out[i] = out[i + 1] = " " + i += 1 + block_comment = False + elif ch != "\n": + out[i] = " " + elif quote is not None: + if ch == "\\": + out[i] = " " + if i + 1 < len(out): + if source[i + 1] != "\n": + out[i + 1] = " " + i += 1 + elif ch == quote: + out[i] = " " + quote = None + elif ch != "\n": + out[i] = " " + elif ch in {'"', "'"}: + out[i] = " " + quote = ch + elif ch == "/" and nxt == "/": + out[i] = out[i + 1] = " " + i += 1 + line_comment = True + elif ch == "/" and nxt == "*": + out[i] = out[i + 1] = " " + i += 1 + block_comment = True + i += 1 + return "".join(out) + + +def _brace_depth(masked: str) -> list[int]: + depth = 0 + result = [0] * (len(masked) + 1) + for i, ch in enumerate(masked): + result[i] = depth + if ch == "{": + depth += 1 + elif ch == "}": + depth = max(0, depth - 1) + result[len(masked)] = depth + return result + + +def _statement_end(masked: str, start: int) -> int | None: + paren = bracket = brace = 0 + for i in range(start, len(masked)): + ch = masked[i] + if ch == "(": + paren += 1 + elif ch == ")": + paren -= 1 + elif ch == "[": + bracket += 1 + elif ch == "]": + bracket -= 1 + elif ch == "{": + brace += 1 + elif ch == "}": + brace -= 1 + elif ch == ";" and paren == bracket == brace == 0: + return i + return None + + +_GUARD = re.compile( + r"(?m)^(?P[ \t]*)if\s*\(\s*!\s*\(\s*" + r"(?P[\"'])(?P[A-Za-z_]\w*)(?P=quote)\s+in\s+getroottable\s*\(\s*\)\s*" + r"\)\s*\)\s*(?P\{\s*)?(?P=slot)\s*<-\s*" +) + +_LEGACY_PERSIST = re.compile( + r"(?m)^(?P[ \t]*)(?P[A-Za-z_]\w*)\s*<-\s*persist\s*\(\s*" + r"(?P[\"'])(?P=slot)(?P=quote)\s*,\s*function\s*\(\s*\)\s*\{\s*return\s+" +) + + +def _guard_edits(source: str, masked: str, depths: list[int]) -> list[Edit]: + edits: list[Edit] = [] + for match in _GUARD.finditer(source): + if depths[match.start()] != 0: + continue + expr_end = _statement_end(masked, match.end()) + if expr_end is None: + continue + end = expr_end + 1 + if match.group("brace") is not None: + closing = re.match(r"[ \t]*\}[ \t]*(?:\r?\n)?", source[end:]) + if closing is None: + continue + end += closing.end() + expression = source[match.end():expr_end].strip() + replacement = f"{match.group('indent')}persist {match.group('slot')} = {expression}" + if source[end - 1:end] == "\n": + replacement += "\n" + edits.append(Edit(match.start(), end, replacement, "guard", match.group("slot"))) + return edits + + +def _legacy_persist_edits(source: str, masked: str, depths: list[int]) -> list[Edit]: + edits: list[Edit] = [] + for match in _LEGACY_PERSIST.finditer(source): + if depths[match.start()] != 0: + continue + expr_end = _statement_end(masked, match.end()) + if expr_end is None: + continue + tail = re.match(r";\s*\}\s*\)\s*;?", source[expr_end:]) + if tail is None: + continue + end = expr_end + tail.end() + expression = source[match.end():expr_end].strip() + replacement = f"{match.group('indent')}persist {match.group('slot')} = {expression}" + edits.append(Edit(match.start(), end, replacement, "persist-call", match.group("slot"))) + return edits + + +def migrate_source(source: str) -> tuple[str, list[Edit]]: + masked = _masked(source) + depths = _brace_depth(masked) + edits = _guard_edits(source, masked, depths) + _legacy_persist_edits(source, masked, depths) + edits.sort(key=lambda edit: edit.start) + for previous, current in zip(edits, edits[1:]): + if previous.end > current.start: + raise ValueError(f"overlapping migration edits for {previous.name} and {current.name}") + migrated = source + for edit in reversed(edits): + migrated = migrated[:edit.start] + edit.replacement + migrated[edit.end:] + return migrated, edits + + +def nut_files(root: Path) -> list[Path]: + return sorted( + path + for path in root.rglob("*.nut") + if not any(part in EXCLUDED_DIRS for part in path.relative_to(root).parts) + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--write", action="store_true", help="rewrite migratable files in place") + parser.add_argument("--check", action="store_true", help="fail if migratable legacy syntax remains") + args = parser.parse_args() + + root = args.root.resolve() + files = nut_files(root) + changed: list[tuple[Path, list[Edit]]] = [] + for path in files: + source = path.read_text(encoding="utf-8-sig") + migrated, edits = migrate_source(source) + if not edits: + continue + changed.append((path, edits)) + if args.write: + path.write_text(migrated, encoding="utf-8", newline="") + + action = "migrated" if args.write else "migratable" + edit_count = sum(len(edits) for _, edits in changed) + print(f"EveScript scan: {len(files)} .nut files, {edit_count} {action} declarations in {len(changed)} files") + for path, edits in changed: + names = ", ".join(edit.name for edit in edits) + print(f" {path.relative_to(root).as_posix()}: {names}") + if args.check and changed: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_migrate_evescript.py b/scripts/tests/test_migrate_evescript.py new file mode 100644 index 00000000..a93e7b51 --- /dev/null +++ b/scripts/tests/test_migrate_evescript.py @@ -0,0 +1,55 @@ +from pathlib import Path +import importlib.util +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("migrate_evescript", ROOT / "scripts" / "migrate_evescript.py") +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +def migrate(source: str) -> str: + return MODULE.migrate_source(source)[0] + + +class MigrateEveScriptTests(unittest.TestCase): + def test_migrates_root_guard_with_nested_initializer(self): + source = 'if (!("state" in getroottable())) state <- { items = [1, 2], label = ";" };\n' + self.assertEqual(migrate(source), 'persist state = { items = [1, 2], label = ";" }\n') + + def test_migrates_multiline_guard(self): + source = 'if (!("world" in getroottable()))\n world <- make_world(\n 0.0, 980.0);\n' + self.assertEqual(migrate(source), 'persist world = make_world(\n 0.0, 980.0)\n') + + def test_migrates_legacy_persist_call(self): + source = '''game <- persist("game", function() { + return { + score = 0, + title = "ready; go" + }; +}); +''' + self.assertEqual(migrate(source), '''persist game = { + score = 0, + title = "ready; go" + } +''') + + def test_does_not_migrate_nested_dynamic_guard(self): + source = '''function update() { + if (!("yaw" in getroottable())) yaw <- 0.0; +} +''' + self.assertEqual(migrate(source), source) + + def test_does_not_touch_capability_check(self): + source = 'if ("gfx" in getroottable()) gfx.clear();\n' + self.assertEqual(migrate(source), source) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 76f827fa..76ef8162 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -3,6 +3,30 @@ create_module(EVCommon common) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/common/config.h.in common/config.h) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/common/BuildInfo.h.in common/BuildInfo.h) +# Generate the language-level contract from the same addFunc declarations that +# SimpleSquirrel compiles. Keeping this as a build input makes stale or +# parameter-less bindings fail before the engine is linked. +find_package(Python3 REQUIRED COMPONENTS Interpreter) +file(GLOB_RECURSE _eve_binding_contract_inputs CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/src/engine/*.h" + "${CMAKE_SOURCE_DIR}/src/engine/*.hpp" + "${CMAKE_SOURCE_DIR}/src/engine/*.cpp" + "${CMAKE_SOURCE_DIR}/src/modules/*.h" + "${CMAKE_SOURCE_DIR}/src/modules/*.hpp" + "${CMAKE_SOURCE_DIR}/src/modules/*.cpp") +string(REPLACE ";" "," _eve_binding_contract_modules "${EVE_ENABLED_MODULES}") +set(_eve_binding_contract_cpp "${CMAKE_CURRENT_BINARY_DIR}/common/BindingContracts.generated.cpp") +add_custom_command( + OUTPUT "${_eve_binding_contract_cpp}" + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/scripts/generate_binding_contracts.py" + --modules "${_eve_binding_contract_modules}" + --platform "${BUILD_PLATFORM}" + --output "${_eve_binding_contract_cpp}" + DEPENDS "${CMAKE_SOURCE_DIR}/scripts/generate_binding_contracts.py" ${_eve_binding_contract_inputs} + COMMENT "Generating strict EveScript Binding Contracts" + VERBATIM) +target_sources(EVCommon PRIVATE "${_eve_binding_contract_cpp}") + # Core/headless/server profiles intentionally stop after EVCommon. They do not # create cmdline, a window or a renderer, so a profile build cannot accidentally # acquire the interactive host through an unconditional executable. diff --git a/src/engine/cmdline/Create.cpp b/src/engine/cmdline/Create.cpp index 0c4d3110..e74d9716 100644 --- a/src/engine/cmdline/Create.cpp +++ b/src/engine/cmdline/Create.cpp @@ -1,8 +1,8 @@ #include "cmdline.h" #include -#include #include +#include #include using namespace std::filesystem; @@ -38,27 +38,35 @@ CMD_REG(CreateArgs); int Cmdline::Create(std::string path, std::string projectName) { if (projectName.empty()) projectName = "mygame"; std::filesystem::path dir = std::filesystem::path(path) / projectName; - std::error_code ec; + std::error_code ec; std::filesystem::create_directories(dir, ec); if (ec) { cerr << rang::fg::red << "Failed to create " << dir << ": " << ec.message() << rang::fg::reset << endl; return 1; } - const std::string config = R"(config = { width=800 height=600 title=")" + projectName + - R"(" hotReload=true };)"; - const std::string main = R"(// EVEngine minimal game template. + const std::string config = R"(// EVEngine project configuration. +config <- { + width = 800 + height = 600 + title = ")" + projectName + + R"(" + hotReload = true + modules = ["gfx"] +} +)"; + const std::string main = R"(// EVEngine minimal game template. // Frame: eve_init (once) -> eve_update(dt) -> eve_render(). -// Guard mutable state so hot reload (re-dofile) does not reset it. -if (!("boxX" in getroottable())) boxX <- 0.0; -if (!("boxY" in getroottable())) boxY <- 100.0; -if (!("vx" in getroottable())) vx <- 240.0; +// EveScript persist declarations retain mutable state across hot reload. +persist boxX: float = 0.0 +persist boxY: float = 100.0 +persist vx: float = 240.0 eve_init = function() { gfx.setBackgroundColor(0.08, 0.10, 0.20, 1.0); }; -eve_update = function(dt) { +eve_update = function(dt: float) { boxX += vx * dt; if (boxX > config.width || boxX < 0.0) vx = -vx; }; diff --git a/src/engine/cmdline/LanguageIndex.cpp b/src/engine/cmdline/LanguageIndex.cpp new file mode 100644 index 00000000..e22777da --- /dev/null +++ b/src/engine/cmdline/LanguageIndex.cpp @@ -0,0 +1,389 @@ +#include "cmdline/LanguageIndex.h" + +#include "common/ScriptModule.h" + +#include +#include +#include + +namespace eve::cmd::lsp { +namespace { + +enum class TokenKind { Identifier, String, Symbol }; + +struct Token { + TokenKind kind = TokenKind::Symbol; + std::string text; + size_t offset = 0; + Range range; +}; + +struct ImportBinding { + std::string imported; + std::string local; + std::string targetUri; + size_t importedToken = 0; + size_t localToken = 0; + bool namespaceImport = false; +}; + +bool identifierStart(char value) { return std::isalpha(static_cast(value)) != 0 || value == '_'; } + +bool identifierPart(char value) { return std::isalnum(static_cast(value)) != 0 || value == '_'; } + +bool validIdentifier(std::string_view value) { + if (value.empty() || !identifierStart(value.front())) return false; + return std::all_of(value.begin() + 1, value.end(), identifierPart); +} + +std::vector tokenize(std::string_view source) { + std::vector result; + size_t line = 0; + size_t column = 0; + const auto advance = [&](char value, size_t& currentLine, size_t& currentColumn) { + if (value == '\n') { + ++currentLine; + currentColumn = 0; + } else { + ++currentColumn; + } + }; + + for (size_t i = 0; i < source.size();) { + const char value = source[i]; + const char next = i + 1 < source.size() ? source[i + 1] : 0; + if (std::isspace(static_cast(value)) != 0) { + advance(value, line, column); + ++i; + continue; + } + if (value == '/' && next == '/') { + while (i < source.size() && source[i] != '\n') { + ++i; + ++column; + } + continue; + } + if (value == '/' && next == '*') { + advance(value, line, column); + advance(next, line, column); + i += 2; + while (i < source.size()) { + const char current = source[i]; + if (current == '*' && i + 1 < source.size() && source[i + 1] == '/') { + advance(current, line, column); + advance('/', line, column); + i += 2; + break; + } + advance(current, line, column); + ++i; + } + continue; + } + + const size_t tokenOffset = i; + const Position start{line, column}; + if (identifierStart(value)) { + ++i; + ++column; + while (i < source.size() && identifierPart(source[i])) { + ++i; + ++column; + } + result.push_back({TokenKind::Identifier, + std::string(source.substr(tokenOffset, i - tokenOffset)), + tokenOffset, + {start, {line, column}}}); + continue; + } + + const bool verbatim = value == '@' && (next == '"' || next == '\''); + if (value == '"' || value == '\'' || verbatim) { + const char quote = verbatim ? next : value; + if (verbatim) { + i += 2; + column += 2; + } else { + ++i; + ++column; + } + std::string text; + while (i < source.size()) { + const char current = source[i]; + if (!verbatim && current == '\\' && i + 1 < source.size()) { + text += source[i + 1]; + advance(current, line, column); + ++i; + advance(source[i], line, column); + ++i; + continue; + } + if (current == quote) { + if (verbatim && i + 1 < source.size() && source[i + 1] == quote) { + text += quote; + i += 2; + column += 2; + continue; + } + ++i; + ++column; + break; + } + text += current; + advance(current, line, column); + ++i; + } + result.push_back({TokenKind::String, std::move(text), tokenOffset, {start, {line, column}}}); + continue; + } + + ++i; + ++column; + result.push_back({TokenKind::Symbol, std::string(1, value), tokenOffset, {start, {line, column}}}); + } + return result; +} + +std::string resolveImport(std::string_view importer, std::string_view specifier) { + script::ScriptModuleRequest request{std::string(importer), std::string(specifier)}; + std::string canonical; + std::string error; + return script::ScriptModuleResolver::canonicalize(request, canonical, error) ? canonical : std::string{}; +} + +bool contains(const Range& range, Position position) { + if (position.line < range.start.line || position.line > range.end.line) return false; + if (position.line == range.start.line && position.character < range.start.character) return false; + if (position.line == range.end.line && position.character > range.end.character) return false; + return true; +} + +} // namespace + +struct WorkspaceIndex::Unit { + std::string canonicalUri; + std::string clientUri; + std::string source; + std::vector tokens; + std::unordered_map declarations; + std::unordered_map exports; + std::vector imports; +}; + +struct WorkspaceIndex::Target { + std::string definingUri; + std::string name; + std::string localUri; + std::string localName; + bool localAlias = false; +}; + +struct WorkspaceIndex::Impl { + std::unordered_map units; +}; + +WorkspaceIndex::WorkspaceIndex() : impl_(std::make_unique()) {} +WorkspaceIndex::~WorkspaceIndex() = default; + +void WorkspaceIndex::update(std::string canonicalUri, std::string clientUri, std::string source) { + Unit next; + next.canonicalUri = std::move(canonicalUri); + next.clientUri = std::move(clientUri); + next.source = std::move(source); + next.tokens = tokenize(next.source); + + const auto& tokens = next.tokens; + for (size_t i = 0; i < tokens.size(); ++i) { + if (tokens[i].kind != TokenKind::Identifier) continue; + const std::string& word = tokens[i].text; + if ((word == "local" || word == "function" || word == "class" || word == "const" || word == "persist") && + i + 1 < tokens.size() && tokens[i + 1].kind == TokenKind::Identifier) { + next.declarations.try_emplace(tokens[i + 1].text, i + 1); + } + if (word == "export" && i + 2 < tokens.size() && tokens[i + 1].kind == TokenKind::Identifier && + (tokens[i + 1].text == "function" || tokens[i + 1].text == "class" || tokens[i + 1].text == "const") && + tokens[i + 2].kind == TokenKind::Identifier) { + next.declarations.try_emplace(tokens[i + 2].text, i + 2); + next.exports[tokens[i + 2].text] = i + 2; + } + if (word != "import" || i + 1 >= tokens.size()) continue; + + size_t cursor = i + 1; + struct Pending { + std::string imported; + std::string local; + size_t importedToken; + size_t localToken; + bool ns; + }; + std::vector pending; + if (tokens[cursor].text == "{") { + ++cursor; + while (cursor < tokens.size() && tokens[cursor].text != "}") { + if (tokens[cursor].kind != TokenKind::Identifier) { + ++cursor; + continue; + } + Pending binding{tokens[cursor].text, tokens[cursor].text, cursor, cursor, false}; + ++cursor; + if (cursor + 1 < tokens.size() && tokens[cursor].text == "as" && + tokens[cursor + 1].kind == TokenKind::Identifier) { + binding.local = tokens[cursor + 1].text; + binding.localToken = cursor + 1; + cursor += 2; + } + pending.push_back(std::move(binding)); + if (cursor < tokens.size() && tokens[cursor].text == ",") ++cursor; + } + if (cursor < tokens.size()) ++cursor; + } else if (tokens[cursor].text == "*" && cursor + 2 < tokens.size() && tokens[cursor + 1].text == "as" && + tokens[cursor + 2].kind == TokenKind::Identifier) { + pending.push_back({"*", tokens[cursor + 2].text, cursor, cursor + 2, true}); + cursor += 3; + } + if (pending.empty() || cursor + 1 >= tokens.size() || tokens[cursor].text != "from" || + tokens[cursor + 1].kind != TokenKind::String) + continue; + const std::string target = resolveImport(next.canonicalUri, tokens[cursor + 1].text); + for (const Pending& binding : pending) + next.imports.push_back( + {binding.imported, binding.local, target, binding.importedToken, binding.localToken, binding.ns}); + } + impl_->units[next.canonicalUri] = std::move(next); +} + +void WorkspaceIndex::remove(std::string_view canonicalUri) { impl_->units.erase(std::string(canonicalUri)); } + +const WorkspaceIndex::Unit* WorkspaceIndex::unit(std::string_view canonicalUri) const { + const auto found = impl_->units.find(std::string(canonicalUri)); + return found == impl_->units.end() ? nullptr : &found->second; +} + +std::optional WorkspaceIndex::targetAt(const Unit& current, Position position) const { + size_t tokenIndex = current.tokens.size(); + for (size_t i = 0; i < current.tokens.size(); ++i) { + if (current.tokens[i].kind == TokenKind::Identifier && contains(current.tokens[i].range, position)) { + tokenIndex = i; + break; + } + } + if (tokenIndex == current.tokens.size()) return std::nullopt; + const std::string& name = current.tokens[tokenIndex].text; + for (const ImportBinding& binding : current.imports) { + if (!binding.namespaceImport || tokenIndex < 2) continue; + if (current.tokens[tokenIndex - 2].kind == TokenKind::Identifier && + current.tokens[tokenIndex - 2].text == binding.local && current.tokens[tokenIndex - 1].text == ".") + return Target{binding.targetUri, name, current.canonicalUri, name, false}; + } + for (const ImportBinding& binding : current.imports) { + if (tokenIndex == binding.importedToken) + return Target{binding.targetUri, binding.imported, current.canonicalUri, binding.local, false}; + if (name == binding.local) { + const bool alias = binding.local != binding.imported; + return Target{binding.targetUri, binding.imported, current.canonicalUri, binding.local, alias}; + } + } + if (current.exports.find(name) != current.exports.end()) + return Target{current.canonicalUri, name, current.canonicalUri, name, false}; + if (current.declarations.find(name) != current.declarations.end()) + return Target{current.canonicalUri, name, current.canonicalUri, name, true}; + return std::nullopt; +} + +std::optional WorkspaceIndex::targetDefinition(const Target& target) const { + const Unit* defining = unit(target.definingUri); + if (defining == nullptr) return std::nullopt; + const auto exported = defining->exports.find(target.name); + const auto declared = defining->declarations.find(target.name); + if (exported != defining->exports.end()) + return Location{defining->clientUri, defining->tokens[exported->second].range}; + if (declared != defining->declarations.end()) + return Location{defining->clientUri, defining->tokens[declared->second].range}; + return std::nullopt; +} + +std::vector WorkspaceIndex::targetReferences(const Target& target, bool includeDeclaration) const { + std::vector result; + const auto appendNamed = [&](const Unit& source, std::string_view name, bool declaration) { + std::unordered_set declarationTokens; + for (const auto& [_, token] : source.declarations) declarationTokens.insert(token); + for (size_t i = 0; i < source.tokens.size(); ++i) { + if (source.tokens[i].kind != TokenKind::Identifier || source.tokens[i].text != name) continue; + const bool isDeclaration = declarationTokens.count(i) != 0; + if (!declaration && isDeclaration) continue; + if (!includeDeclaration && isDeclaration) continue; + result.push_back({source.clientUri, source.tokens[i].range}); + } + }; + + if (target.localAlias) { + if (const Unit* local = unit(target.localUri)) appendNamed(*local, target.localName, true); + } else { + if (const Unit* defining = unit(target.definingUri)) appendNamed(*defining, target.name, true); + for (const auto& [_, source] : impl_->units) { + for (const ImportBinding& binding : source.imports) { + if (binding.targetUri != target.definingUri) continue; + if (binding.namespaceImport) { + for (size_t i = 0; i + 2 < source.tokens.size(); ++i) { + if (source.tokens[i].kind != TokenKind::Identifier || source.tokens[i].text != binding.local || + source.tokens[i + 1].text != "." || source.tokens[i + 2].kind != TokenKind::Identifier || + source.tokens[i + 2].text != target.name) + continue; + result.push_back({source.clientUri, source.tokens[i + 2].range}); + } + continue; + } + if (binding.imported != target.name) continue; + result.push_back({source.clientUri, source.tokens[binding.importedToken].range}); + if (binding.local != binding.imported) continue; + for (size_t i = 0; i < source.tokens.size(); ++i) { + if (source.tokens[i].kind != TokenKind::Identifier || source.tokens[i].text != binding.local) + continue; + result.push_back({source.clientUri, source.tokens[i].range}); + } + } + } + } + std::sort(result.begin(), result.end(), [](const Location& a, const Location& b) { + if (a.uri != b.uri) return a.uri < b.uri; + if (a.range.start.line != b.range.start.line) return a.range.start.line < b.range.start.line; + return a.range.start.character < b.range.start.character; + }); + result.erase(std::unique(result.begin(), result.end(), + [](const Location& a, const Location& b) { + return a.uri == b.uri && a.range.start.line == b.range.start.line && + a.range.start.character == b.range.start.character; + }), + result.end()); + return result; +} + +std::optional WorkspaceIndex::definition(std::string_view canonicalUri, Position position) const { + const Unit* current = unit(canonicalUri); + if (current == nullptr) return std::nullopt; + const auto target = targetAt(*current, position); + return target ? targetDefinition(*target) : std::nullopt; +} + +std::vector WorkspaceIndex::references(std::string_view canonicalUri, Position position, + bool includeDeclaration) const { + const Unit* current = unit(canonicalUri); + if (current == nullptr) return {}; + const auto target = targetAt(*current, position); + return target ? targetReferences(*target, includeDeclaration) : std::vector{}; +} + +std::optional> WorkspaceIndex::rename(std::string_view canonicalUri, Position position, + std::string_view newName) const { + if (!validIdentifier(newName)) return std::nullopt; + const Unit* current = unit(canonicalUri); + if (current == nullptr) return std::nullopt; + const auto target = targetAt(*current, position); + if (!target || !targetDefinition(*target)) return std::nullopt; + std::vector edits; + for (const Location& location : targetReferences(*target, true)) edits.push_back({location, std::string(newName)}); + return edits.empty() ? std::nullopt : std::optional>(std::move(edits)); +} + +} // namespace eve::cmd::lsp diff --git a/src/engine/cmdline/LanguageIndex.h b/src/engine/cmdline/LanguageIndex.h new file mode 100644 index 00000000..ee3a41dd --- /dev/null +++ b/src/engine/cmdline/LanguageIndex.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace eve::cmd::lsp { + +struct Position { + size_t line = 0; + size_t character = 0; +}; + +struct Range { + Position start; + Position end; +}; + +struct Location { + std::string uri; + Range range; +}; + +struct TextEdit { + Location location; + std::string newText; +}; + +/** @brief Cross-file semantic index for EveScript source modules. */ +class WorkspaceIndex { +public: + WorkspaceIndex(); + ~WorkspaceIndex(); + WorkspaceIndex(const WorkspaceIndex&) = delete; + WorkspaceIndex& operator=(const WorkspaceIndex&) = delete; + + /** @brief Adds or replaces one canonical source unit. */ + void update(std::string canonicalUri, std::string clientUri, std::string source); + /** @brief Removes one source unit from the index. */ + void remove(std::string_view canonicalUri); + /** @brief Finds the declaration targeted by the identifier at a position. */ + std::optional definition(std::string_view canonicalUri, Position position) const; + /** @brief Finds project references for the identifier at a position. */ + std::vector references(std::string_view canonicalUri, Position position, bool includeDeclaration) const; + /** @brief Computes a project-wide rename, or nullopt for an invalid/unresolved request. */ + std::optional> rename(std::string_view canonicalUri, Position position, + std::string_view newName) const; + +private: + struct Unit; + struct Target; + struct Impl; + + const Unit* unit(std::string_view canonicalUri) const; + std::optional targetAt(const Unit& unit, Position position) const; + std::optional targetDefinition(const Target& target) const; + std::vector targetReferences(const Target& target, bool includeDeclaration) const; + std::unique_ptr impl_; +}; + +} // namespace eve::cmd::lsp diff --git a/src/engine/cmdline/LanguageServer.cpp b/src/engine/cmdline/LanguageServer.cpp index 79209310..87bb8620 100644 --- a/src/engine/cmdline/LanguageServer.cpp +++ b/src/engine/cmdline/LanguageServer.cpp @@ -3,12 +3,19 @@ #include +#include #include #include +#if defined(_WIN32) +#include +#include +#endif + #if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) && !defined(EVENGINE_WEBGPU) #include "scripts.h" +#include "cmdline/LanguageIndex.h" #include "common/Runtime.h" #include "common/ScriptCompiler.h" #include "common/ScriptModule.h" @@ -128,20 +135,18 @@ std::string wordAt(std::string_view source, size_t line, size_t character) { const size_t newline = source.find('\n', offset); offset = newline == std::string_view::npos ? source.size() : newline + 1; } - offset = std::min(offset + character, source.size()); + offset = std::min(offset + character, source.size()); size_t begin = offset; size_t end = offset; while (begin > 0 && (std::isalnum(static_cast(source[begin - 1])) || source[begin - 1] == '_')) --begin; - while (end < source.size() && - (std::isalnum(static_cast(source[end])) || source[end] == '_')) - ++end; + while (end < source.size() && (std::isalnum(static_cast(source[end])) || source[end] == '_')) ++end; return std::string(source.substr(begin, end - begin)); } std::vector moduleNames(std::string_view source, const std::regex& pattern) { - const std::string owned(source); - std::vector result; + const std::string owned(source); + std::vector result; for (std::sregex_iterator it(owned.begin(), owned.end(), pattern), end; it != end; ++it) result.push_back((*it)[1].str()); return result; @@ -154,19 +159,20 @@ public: runtime_(2048, ssq::Libs::ALL) { runtime_.scriptModules().registerProvider( std::make_shared(std::filesystem::path(root_)), 100); - const std::string contract = module_list_content ? module_list_content : ""; - const size_t split = contract.find("eve_module_contract"); + const std::string contract = module_list_content ? module_list_content : ""; + const size_t split = contract.find("eve_module_contract"); static const std::regex slotPattern(R"(\bslot\s*=\s*["']([^"']+)["'])"); static const std::regex namePattern(R"(\bname\s*=\s*["']([^"']+)["'])"); activeModules_ = moduleNames(contract.substr(0, split), slotPattern); - knownModules_ = moduleNames(split == std::string::npos ? std::string_view{} : std::string_view(contract).substr(split), - namePattern); + knownModules_ = moduleNames( + split == std::string::npos ? std::string_view{} : std::string_view(contract).substr(split), namePattern); + indexProject(); } int run() { std::string body; while (readFrame(body)) { - Poco::JSON::Parser parser; + Poco::JSON::Parser parser; Poco::JSON::Object::Ptr request; try { request = parser.parse(body).extract(); @@ -194,6 +200,9 @@ public: capabilities->set("textDocumentSync", sync); capabilities->set("completionProvider", completion); capabilities->set("hoverProvider", true); + capabilities->set("definitionProvider", true); + capabilities->set("referencesProvider", true); + capabilities->set("renameProvider", true); Poco::JSON::Object::Ptr diagnosticProvider = new Poco::JSON::Object(); diagnosticProvider->set("interFileDependencies", true); diagnosticProvider->set("workspaceDiagnostics", false); @@ -208,23 +217,27 @@ public: return shutdown_ ? 0 : 1; } else if (method == "textDocument/didOpen") { auto document = params ? params->getObject("textDocument") : nullptr; - if (document) update(document->optValue("uri", ""), - document->optValue("text", "")); + if (document) + update(document->optValue("uri", ""), document->optValue("text", "")); } else if (method == "textDocument/didChange") { - const std::string uri = documentUri(params); - auto changes = params ? params->getArray("contentChanges") : nullptr; + const std::string uri = documentUri(params); + auto changes = params ? params->getArray("contentChanges") : nullptr; if (changes && changes->size() != 0) { auto change = changes->getObject(static_cast(changes->size() - 1)); if (change) update(uri, change->optValue("text", "")); } } else if (method == "textDocument/didClose") { - const std::string uri = documentUri(params); - documents_.erase(uri); - canonical_.erase(uri); + close(documentUri(params)); } else if (method == "textDocument/completion") { writeFrame(response(id, Poco::Dynamic::Var(completion(params)))); } else if (method == "textDocument/hover") { writeFrame(response(id, hover(params))); + } else if (method == "textDocument/definition") { + writeFrame(response(id, definition(params))); + } else if (method == "textDocument/references") { + writeFrame(response(id, Poco::Dynamic::Var(references(params)))); + } else if (method == "textDocument/rename") { + writeFrame(response(id, rename(params))); } else if (method == "textDocument/diagnostic") { Poco::JSON::Object::Ptr result = new Poco::JSON::Object(); result->set("kind", "full"); @@ -242,6 +255,7 @@ private: if (uri.empty()) return; documents_[uri] = std::move(source); canonical_[uri] = canonicalUri(uri); + index_.update(canonical_[uri], uri, documents_[uri]); try { runtime_.compileSource(documents_[uri], canonical_[uri]); } catch (const std::exception&) { @@ -256,17 +270,33 @@ private: writeFrame(notification); } - Poco::JSON::Array::Ptr diagnostics(const std::string& uri) const { - Poco::JSON::Array::Ptr result = new Poco::JSON::Array(); + void close(const std::string& uri) { const auto canonical = canonical_.find(uri); - const std::string& identity = canonical == canonical_.end() ? uri : canonical->second; - auto diagnostics = runtime_.scriptCompiler().diagnostics(identity); - const auto document = documents_.find(uri); - const bool isProjectConfig = identity == "game:/config.nut" || - (uri.size() >= 11 && uri.compare(uri.size() - 11, 11, "/config.nut") == 0); + if (canonical != canonical_.end()) { + const std::filesystem::path path = pathForCanonical(canonical->second); + std::ifstream input(path, std::ios::binary); + if (input) { + std::string source{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + index_.update(canonical->second, clientUri(path), std::move(source)); + } else { + index_.remove(canonical->second); + } + } + documents_.erase(uri); + canonical_.erase(uri); + } + + Poco::JSON::Array::Ptr diagnostics(const std::string& uri) const { + Poco::JSON::Array::Ptr result = new Poco::JSON::Array(); + const auto canonical = canonical_.find(uri); + const std::string& identity = canonical == canonical_.end() ? uri : canonical->second; + auto diagnostics = runtime_.scriptCompiler().diagnostics(identity); + const auto document = documents_.find(uri); + const bool isProjectConfig = identity == "game:/config.nut" || + (uri.size() >= 11 && uri.compare(uri.size() - 11, 11, "/config.nut") == 0); if (document != documents_.end() && isProjectConfig) { - auto configDiagnostics = script::ScriptCompiler::validateProjectConfig( - document->second, identity, knownModules_, activeModules_); + auto configDiagnostics = script::ScriptCompiler::validateProjectConfig(document->second, identity, + knownModules_, activeModules_); diagnostics.insert(diagnostics.end(), configDiagnostics.begin(), configDiagnostics.end()); } for (const script::ScriptDiagnostic& diagnostic : diagnostics) { @@ -292,17 +322,17 @@ private: Poco::JSON::Array::Ptr completion(const Poco::JSON::Object::Ptr& params) const { Poco::JSON::Array::Ptr result = new Poco::JSON::Array(); - const std::string uri = documentUri(params); - const auto found = documents_.find(uri); - std::string prefix; + const std::string uri = documentUri(params); + const auto found = documents_.find(uri); + std::string prefix; if (found != documents_.end() && params) { auto position = params->getObject("position"); if (position) prefix = wordAt(found->second, static_cast(position->optValue("line", 0)), static_cast(position->optValue("character", 0))); } - const auto canonical = canonical_.find(uri); - const std::string& identity = canonical == canonical_.end() ? uri : canonical->second; + const auto canonical = canonical_.find(uri); + const std::string& identity = canonical == canonical_.end() ? uri : canonical->second; for (const script::ScriptCompletion& completion : runtime_.scriptCompiler().completions(identity, prefix)) { Poco::JSON::Object::Ptr item = new Poco::JSON::Object(); item->set("label", completion.label); @@ -315,16 +345,16 @@ private: } Poco::Dynamic::Var hover(const Poco::JSON::Object::Ptr& params) const { - const std::string uri = documentUri(params); + const std::string uri = documentUri(params); const auto found = documents_.find(uri); if (found == documents_.end() || !params) return {}; auto position = params->getObject("position"); if (!position) return {}; - const std::string symbol = wordAt(found->second, static_cast(position->optValue("line", 0)), - static_cast(position->optValue("character", 0))); - const auto canonical = canonical_.find(uri); - const std::string& identity = canonical == canonical_.end() ? uri : canonical->second; - const auto value = runtime_.scriptCompiler().hover(identity, symbol); + const std::string symbol = wordAt(found->second, static_cast(position->optValue("line", 0)), + static_cast(position->optValue("character", 0))); + const auto canonical = canonical_.find(uri); + const std::string& identity = canonical == canonical_.end() ? uri : canonical->second; + const auto value = runtime_.scriptCompiler().hover(identity, symbol); if (!value) return {}; Poco::JSON::Object::Ptr contents = new Poco::JSON::Object(); contents->set("kind", "markdown"); @@ -334,10 +364,142 @@ private: return Poco::Dynamic::Var(result); } + static Poco::JSON::Object::Ptr rangeObject(const lsp::Range& value) { + Poco::JSON::Object::Ptr start = new Poco::JSON::Object(); + start->set("line", static_cast(value.start.line)); + start->set("character", static_cast(value.start.character)); + Poco::JSON::Object::Ptr end = new Poco::JSON::Object(); + end->set("line", static_cast(value.end.line)); + end->set("character", static_cast(value.end.character)); + Poco::JSON::Object::Ptr result = new Poco::JSON::Object(); + result->set("start", start); + result->set("end", end); + return result; + } + + static Poco::JSON::Object::Ptr locationObject(const lsp::Location& value) { + Poco::JSON::Object::Ptr result = new Poco::JSON::Object(); + result->set("uri", value.uri); + result->set("range", rangeObject(value.range)); + return result; + } + + lsp::Position requestPosition(const Poco::JSON::Object::Ptr& params) const { + auto position = params ? params->getObject("position") : nullptr; + return position ? lsp::Position{static_cast(std::max(position->optValue("line", 0), 0)), + static_cast(std::max(position->optValue("character", 0), 0))} + : lsp::Position{}; + } + + std::string identity(const std::string& uri) const { + const auto found = canonical_.find(uri); + return found == canonical_.end() ? canonicalUri(uri) : found->second; + } + + Poco::Dynamic::Var definition(const Poco::JSON::Object::Ptr& params) const { + const auto value = index_.definition(identity(documentUri(params)), requestPosition(params)); + return value ? Poco::Dynamic::Var(locationObject(*value)) : Poco::Dynamic::Var(); + } + + Poco::JSON::Array::Ptr references(const Poco::JSON::Object::Ptr& params) const { + bool includeDeclaration = false; + if (auto context = params ? params->getObject("context") : nullptr) + includeDeclaration = context->optValue("includeDeclaration", false); + Poco::JSON::Array::Ptr result = new Poco::JSON::Array(); + for (const lsp::Location& location : + index_.references(identity(documentUri(params)), requestPosition(params), includeDeclaration)) + result->add(locationObject(location)); + return result; + } + + Poco::Dynamic::Var rename(const Poco::JSON::Object::Ptr& params) const { + const std::string newName = params ? params->optValue("newName", "") : std::string{}; + const auto edits = index_.rename(identity(documentUri(params)), requestPosition(params), newName); + if (!edits) return {}; + Poco::JSON::Object::Ptr changes = new Poco::JSON::Object(); + std::unordered_map byUri; + for (const lsp::TextEdit& edit : *edits) { + auto& list = byUri[edit.location.uri]; + if (!list) list = new Poco::JSON::Array(); + Poco::JSON::Object::Ptr item = new Poco::JSON::Object(); + item->set("range", rangeObject(edit.location.range)); + item->set("newText", edit.newText); + list->add(item); + } + for (const auto& [uri, list] : byUri) changes->set(uri, list); + Poco::JSON::Object::Ptr result = new Poco::JSON::Object(); + result->set("changes", changes); + return Poco::Dynamic::Var(result); + } + + std::filesystem::path pathForCanonical(std::string_view canonical) const { + constexpr std::string_view prefix = "game:/"; + return canonical.rfind(prefix, 0) == 0 + ? std::filesystem::path(root_) / std::string(canonical.substr(prefix.size())) + : std::filesystem::path{}; + } + + static std::string clientUri(const std::filesystem::path& path) { + std::string generic = std::filesystem::absolute(path).lexically_normal().generic_string(); + std::string encoded; + encoded.reserve(generic.size()); + for (const char value : generic) { + if (value == ' ') + encoded += "%20"; + else if (value == '#') + encoded += "%23"; + else if (value == '%') + encoded += "%25"; + else + encoded += value; + } + return "file:///" + encoded; + } + + void indexProject() { + std::error_code error; + for (std::filesystem::recursive_directory_iterator it(root_, error), end; it != end && !error; + it.increment(error)) { + if (it->is_directory(error)) { + const std::string name = it->path().filename().string(); + if (name == ".git" || name == "build" || name == "third-party") it.disable_recursion_pending(); + continue; + } + if (!it->is_regular_file(error) || it->path().extension() != ".nut") continue; + std::ifstream input(it->path(), std::ios::binary); + if (!input) continue; + std::string source{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + const std::string uri = clientUri(it->path()); + const auto relative = std::filesystem::relative(it->path(), std::filesystem::path(root_), error); + if (error || relative.empty() || relative.generic_string().rfind("..", 0) == 0) continue; + index_.update("game:/" + relative.generic_string(), uri, std::move(source)); + } + } + std::string canonicalUri(const std::string& uri) const { constexpr std::string_view filePrefix = "file:///"; if (uri.rfind(filePrefix, 0) != 0) return uri; std::string path = uri.substr(filePrefix.size()); + for (size_t cursor = 0; cursor + 2 < path.size();) { + if (path[cursor] != '%') { + ++cursor; + continue; + } + const auto hex = [](char value) -> int { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + if (value >= 'A' && value <= 'F') return value - 'A' + 10; + return -1; + }; + const int high = hex(path[cursor + 1]); + const int low = hex(path[cursor + 2]); + if (high < 0 || low < 0) { + ++cursor; + continue; + } + path.replace(cursor, 3, 1, static_cast((high << 4) | low)); + ++cursor; + } #if defined(_WIN32) if (path.size() >= 2 && path[1] == ':') { } else { @@ -346,14 +508,15 @@ private: #else path.insert(path.begin(), '/'); #endif - std::error_code error; - const auto relative = std::filesystem::relative(std::filesystem::path(path), std::filesystem::path(root_), error); - if (error || relative.empty() || relative.generic_string().rfind("..", 0) == 0) return uri; + const auto relative = std::filesystem::path(path).lexically_normal().lexically_relative( + std::filesystem::path(root_).lexically_normal()); + if (relative.empty() || *relative.begin() == "..") return uri; return "game:/" + relative.generic_string(); } std::string root_; Runtime runtime_; + lsp::WorkspaceIndex index_; std::unordered_map documents_; std::unordered_map canonical_; std::vector knownModules_; @@ -387,6 +550,12 @@ int Cmdline::LanguageServer(std::string path) { std::cerr << "eve language-server: desktop-only" << std::endl; return 4; #else +#if defined(_WIN32) + if (_setmode(_fileno(stdin), _O_BINARY) == -1 || _setmode(_fileno(stdout), _O_BINARY) == -1) { + std::cerr << "eve language-server: failed to configure binary stdio" << std::endl; + return 4; + } +#endif std::error_code error; if (path.empty()) path = std::filesystem::current_path(error).string(); LanguageServerSession session(std::move(path)); diff --git a/src/engine/cmdline/Run.cpp b/src/engine/cmdline/Run.cpp index 49442753..40bef5e5 100644 --- a/src/engine/cmdline/Run.cpp +++ b/src/engine/cmdline/Run.cpp @@ -3,6 +3,7 @@ #include "common/Module.h" #include "common/Runtime.h" #include "common/ScriptCompiler.h" +#include "common/ScriptModule.h" #include "common/config.h" #include "common/ECS.h" #include "common/CrashLog.h" @@ -472,6 +473,13 @@ int Cmdline::Run(std::string path, std::string root, bool debug, int dapPort, in eve.addFunc("log", [](const std::string& level, const std::string& msg) { eve::recordLogEvent(level, msg); }); + eve.addFunc("reloadScriptModule", [&runtime](const std::string& path) { + std::string canonical; + std::string error; + if (!eve::script::ScriptModuleResolver::canonicalize({"game:/main.nut", path}, canonical, error)) + throw std::runtime_error(error); + return !runtime.scriptModules().reloadAffected(canonical).empty(); + }); // Scene-director authoring kit (src/scripts/scene_director.nut). Host // games load it via `compilestring(eve.sceneDirectorScript)()`; the // MCP tools auto-install it on demand. diff --git a/src/engine/common/BindingContracts.h b/src/engine/common/BindingContracts.h new file mode 100644 index 00000000..4c7db704 --- /dev/null +++ b/src/engine/common/BindingContracts.h @@ -0,0 +1,12 @@ +#pragma once + +#include "common/Export.h" + +namespace eve::script { + +class BindingContractRegistry; + +/** @brief Registers the build-generated contracts for every SimpleSquirrel binding. */ +EVENGINE_API void registerEngineBindingContracts(BindingContractRegistry& registry); + +} // namespace eve::script diff --git a/src/engine/common/GpuTimer.h b/src/engine/common/GpuTimer.h new file mode 100644 index 00000000..780a7e6c --- /dev/null +++ b/src/engine/common/GpuTimer.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +// GPU timing capability. The graphics module (LAYER 3) provides a live +// implementation; lower-layer consumers (e.g. the profiler module) query it via +// eve::cap::query() and cope with nullptr (when graphics is absent / disabled). +// This keeps the interface in common/ so layering is not violated. + +namespace eve::service { + +/** + * @brief Reads the GPU frame timing captured by the renderer via Vulkan + * timestamp queries. + * Providers: eve::graphics::Graphics. + */ +class IGpuTimer { +public: + static constexpr const char *capabilityName = "IGpuTimer"; + virtual ~IGpuTimer() = default; + + /** @brief True when the renderer captured a valid GPU frame this frame. */ + virtual bool gpuTimingAvailable() const = 0; + /** @brief GPU execution time of the last presented frame, in milliseconds. */ + virtual float gpuFrameMs() const = 0; +}; + +} // namespace eve::service diff --git a/src/engine/common/ProcgenSceneSink.h b/src/engine/common/ProcgenSceneSink.h new file mode 100644 index 00000000..649e7d64 --- /dev/null +++ b/src/engine/common/ProcgenSceneSink.h @@ -0,0 +1,52 @@ +#pragma once + +#include "common/Export.h" + +#include +#include +#include + +namespace eve { + +/** @brief Engine-neutral description of one procedural scene instance. */ +struct EVENGINE_API ProcgenInstanceDesc { + std::string id; + std::string asset; + float x = 0.f; + float y = 0.f; + float z = 0.f; + float yaw = 0.f; + float scaleX = 1.f; + float scaleY = 1.f; + float scaleZ = 1.f; + uint32_t seed = 1; +}; + +/** + * @brief Optional scene consumer for procedural instance batches. + * + * The interface lives in common so procgen can publish results without an + * upward dependency on scene. Scene providers must replace a batch atomically + * and preserve stable instance ids across rebuilds. + */ +class EVENGINE_API IProcgenSceneSink { +public: + static constexpr const char* capabilityName = "IProcgenSceneSink"; + + virtual ~IProcgenSceneSink() = default; + /** @brief Create or reconcile all instances in a named batch. */ + virtual bool applyBatch(const std::string& batchId, + const std::vector& instances) = 0; + /** @brief Remove the visible contents of a batch. */ + virtual bool removeBatch(const std::string& batchId) = 0; + /** @brief Number of currently published instances in a batch. */ + virtual int instanceCount(const std::string& batchId) const = 0; + /** @brief Instances newly created by the batch's latest successful apply. */ + virtual int lastCreatedCount(const std::string& batchId) const = 0; + /** @brief Stable-id instances reused by the batch's latest successful apply. */ + virtual int lastReusedCount(const std::string& batchId) const = 0; + /** @brief Instances removed by the batch's latest successful apply or removal. */ + virtual int lastRemovedCount(const std::string& batchId) const = 0; +}; + +} // namespace eve diff --git a/src/engine/common/Profile.cpp b/src/engine/common/Profile.cpp new file mode 100644 index 00000000..eb82f76c --- /dev/null +++ b/src/engine/common/Profile.cpp @@ -0,0 +1,247 @@ +#include "common/Profile.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace eve::prof { + +namespace { +std::atomic g_enabled{false}; + +int64_t nowNs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +// Module id interning (thread-safe). +std::mutex g_modulesMutex; +std::vector g_moduleNames{""}; // index 0 = "default" +std::unordered_map g_moduleIds; + +uint64_t zoneKey(uint32_t module, const char* name) { + return (static_cast(module) << 32) ^ std::hash{}(name); +} +} // namespace + +struct Profiler::ThreadBuffer { + uint32_t threadId = 0; + std::string threadName = "main"; + std::vector records; + std::vector stack; // indices of open zones +}; + +struct Profiler::Frame { + int64_t beginNs = 0; + int64_t endNs = 0; + std::vector samples; +}; + +namespace { +std::mutex g_threadsMutex; +} // namespace + +Profiler::ThreadBuffer& Profiler::currentBuffer() { + static thread_local ThreadBuffer tb; + if (tb.threadId == 0) { + tb.threadId = + static_cast(std::hash{}(std::this_thread::get_id())); + const bool isMain = std::this_thread::get_id() == std::thread::id(); + tb.threadName = isMain ? "main" : "worker"; + std::lock_guard lock(g_threadsMutex); + threads().push_back(&tb); + } + return tb; +} + +std::vector& Profiler::threads() { + static std::vector t; + return t; +} + +Profiler::Frame& Profiler::lastFrameStorage() { + static Frame f; + return f; +} + +bool& Profiler::hasFrameFlag() { + static bool b = false; + return b; +} + +uint32_t Profiler::moduleId(const char* module) { + if (!module || !module[0]) return 0; + std::lock_guard lock(g_modulesMutex); + auto it = g_moduleIds.find(module); + if (it != g_moduleIds.end()) return it->second; + const uint32_t id = static_cast(g_moduleNames.size()); + g_moduleNames.push_back(module); + g_moduleIds.emplace(module, id); + return id; +} + +// --- public API ------------------------------------------------------------- + +void Profiler::setEnabled(bool on) { g_enabled.store(on, std::memory_order_relaxed); } + +bool Profiler::enabled() { return g_enabled.load(std::memory_order_relaxed); } + +void Profiler::zoneBegin(const char* name, const char* module) { + if (!g_enabled.load(std::memory_order_relaxed)) return; + ThreadBuffer& tb = currentBuffer(); + ZoneRecord r; + r.name = name ? name : ""; + r.module = moduleId(module); + r.threadId = tb.threadId; + r.parent = tb.stack.empty() ? -1 : tb.stack.back(); + r.depth = static_cast(tb.stack.size()); + r.startNs = nowNs(); + r.endNs = 0; + r.kind = 0; + const int32_t idx = static_cast(tb.records.size()); + tb.records.push_back(r); + tb.stack.push_back(idx); +} + +void Profiler::zoneEnd() { + if (!g_enabled.load(std::memory_order_relaxed)) return; + ThreadBuffer& tb = currentBuffer(); + if (tb.stack.empty()) return; + const int32_t idx = tb.stack.back(); + tb.stack.pop_back(); + tb.records[static_cast(idx)].endNs = nowNs(); +} + +void Profiler::reset() { + std::lock_guard lock(g_threadsMutex); + for (auto* tb : threads()) { + tb->records.clear(); + tb->stack.clear(); + } + lastFrameStorage() = Frame{}; + hasFrameFlag() = false; +} + +double Profiler::frameMark() { + if (!g_enabled.load(std::memory_order_relaxed)) return 0.0; + + const int64_t t0 = nowNs(); + + // Snapshot and clear every thread buffer. + struct Snap { + uint32_t tid; + std::string tname; + std::vector records; + }; + std::vector snaps; + { + std::lock_guard lock(g_threadsMutex); + for (auto* tb : threads()) { + Snap s; + s.tid = tb->threadId; + s.tname = tb->threadName; + for (auto& r : tb->records) + if (r.endNs == 0) r.endNs = t0; + s.records = std::move(tb->records); + tb->records.clear(); + tb->stack.clear(); + snaps.push_back(std::move(s)); + } + } + + // Aggregate per (module, name): self = own total - sum of children totals. + struct Agg { + double selfMs = 0.0; + double totalMs = 0.0; + int count = 0; + int minDepth = 0x7fffffff; + uint32_t module = 0; + std::string name; + std::string thread; + }; + std::unordered_map aggMap; + + for (const auto& s : snaps) { + const size_t n = s.records.size(); + std::vector total(n, 0); + std::vector child(n, 0); + for (size_t i = 0; i < n; ++i) { + int64_t t = s.records[i].endNs - s.records[i].startNs; + if (t < 0) t = 0; + total[i] = t; + } + for (size_t i = 0; i < n; ++i) { + const int32_t p = s.records[i].parent; + if (p >= 0 && p < static_cast(n)) child[static_cast(p)] += total[i]; + } + for (size_t i = 0; i < n; ++i) { + const auto& r = s.records[i]; + const int64_t self = total[i] - child[i]; + Agg& a = aggMap[zoneKey(r.module, r.name)]; + a.selfMs += static_cast(self) / 1'000'000.0; + a.totalMs += static_cast(total[i]) / 1'000'000.0; + ++a.count; + a.module = r.module; + a.name = r.name ? r.name : ""; + a.thread = s.tname; + if (r.depth < a.minDepth) a.minDepth = r.depth; + } + } + + Frame f; + f.beginNs = t0; + for (auto& kv : aggMap) { + Agg& a = kv.second; + ZoneSample sm; + sm.module = a.module < g_moduleNames.size() ? g_moduleNames[a.module] + : std::string("default"); + sm.name = a.name; + sm.thread = a.thread; + sm.selfMs = a.selfMs; + sm.totalMs = a.totalMs; + sm.count = a.count; + sm.minDepth = a.minDepth; + f.samples.push_back(std::move(sm)); + } + std::sort(f.samples.begin(), f.samples.end(), + [](const ZoneSample& x, const ZoneSample& y) { return x.selfMs > y.selfMs; }); + f.endNs = nowNs(); + + lastFrameStorage() = std::move(f); + hasFrameFlag() = true; + return static_cast(lastFrameStorage().endNs - lastFrameStorage().beginNs) / 1'000'000.0; +} + +bool Profiler::hasFrame() { return hasFrameFlag(); } + +const std::vector& Profiler::lastFrame() { return lastFrameStorage().samples; } + +std::string Profiler::textReport() { + if (!hasFrameFlag()) return "=== Profiler: no frame captured yet ===\n"; + std::ostringstream os; + char buf[64]; + const double totalMs = + static_cast(lastFrameStorage().endNs - lastFrameStorage().beginNs) / 1'000'000.0; + std::snprintf(buf, sizeof(buf), "%.2f", totalMs); + os << "=== Profiler frame (" << buf << " ms) ===\n"; + os << "-- Per-module / per-zone (self time, sorted) --\n"; + for (const auto& s : lastFrameStorage().samples) { + std::snprintf(buf, sizeof(buf), "%.3f", s.selfMs); + os << " [" << s.module << "/" << s.thread << "] " << s.name + << " self=" << buf << " ms"; + std::snprintf(buf, sizeof(buf), "%.3f", s.totalMs); + os << " total=" << buf << " ms"; + if (s.count > 1) os << " x" << s.count; + os << "\n"; + } + return os.str(); +} + +} // namespace eve::prof diff --git a/src/engine/common/Profile.h b/src/engine/common/Profile.h new file mode 100644 index 00000000..28c9dfad --- /dev/null +++ b/src/engine/common/Profile.h @@ -0,0 +1,140 @@ +#pragma once + +#ifndef EVENGINE_COMMON_PROFILE_H +#define EVENGINE_COMMON_PROFILE_H + +#include "common/Export.h" + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Engine-wide profiling core (Tracy / UE Insights style scoped zones). +// +// Any module may sprinkle scoped zones at its per-frame entry points: +// +// void World::update(float dt) { +// EV_PROFILE_MODULE("physics", "World::update"); +// ... +// } +// +// Zones are cheap when disabled (a single atomic branch); when enabled they +// push {name, module, thread, start, end, parent} into a per-thread buffer and +// frameMark() aggregates the frame into a call tree with self/total time per +// module and per zone. This core lives in common/ so lower layers can use it +// without violating the module layering rules. +// --------------------------------------------------------------------------- + +namespace eve::prof { + +/** + * @brief One completed profiling zone (pre-order, parent-linked within a thread). + */ +struct EVENGINE_API ZoneRecord { + const char* name = nullptr; // pointer is static storage, never freed + uint32_t module = 0; // interned module id (0 = default) + uint32_t threadId = 0; + int32_t parent = -1; // index into the same thread's buffer + int16_t depth = 0; + int64_t startNs = 0; + int64_t endNs = 0; // 0 while open + uint8_t kind = 0; // 0 = CPU, 1 = GPU +}; + +/** + * @brief Per-module/per-zone aggregate of the last completed frame. + */ +struct EVENGINE_API ZoneSample { + std::string module; + std::string name; + std::string thread; + double selfMs = 0.0; + double totalMs = 0.0; + int count = 0; + int minDepth = 0x7fffffff; +}; + +/** + * @brief Global zone collector + per-frame aggregator. + * + * All methods are cheap when disabled. Most methods are called from the main + * thread; the collector also snapshots off-main-thread buffers at frameMark(). + */ +class EVENGINE_API Profiler { +public: + /** @brief Turns collection on/off globally (off = single-branch no-op). */ + static void setEnabled(bool on); + /** @brief True when collection is active. */ + static bool enabled(); + + /** @brief Opens a scoped zone on the current thread. */ + static void zoneBegin(const char* name, const char* module = nullptr); + /** @brief Closes the innermost open zone on the current thread. */ + static void zoneEnd(); + + /** + * @brief Finalizes the current frame: snapshots all thread buffers and + * aggregates them into lastFrame(). + * @return Wall time spent aggregating (not the frame duration). + */ + static double frameMark(); + + /** @brief True once at least one frame has been aggregated. */ + static bool hasFrame(); + /** @brief Per-module/per-zone samples of the last frame, sorted by self time. */ + static const std::vector& lastFrame(); + /** @brief Human-readable report of the last frame. */ + static std::string textReport(); + + /** @brief Clears all collected state. */ + static void reset(); + + /** @brief Interns a module name and returns its stable id. */ + static uint32_t moduleId(const char* module); + +private: + struct ThreadBuffer; + struct Frame; + + static ThreadBuffer& currentBuffer(); + + /** @brief Registry of live thread buffers (locked internally). */ + static std::vector& threads(); + /** @brief Storage for the last aggregated frame (main thread only). */ + static Frame& lastFrameStorage(); + static bool& hasFrameFlag(); +}; + +/** + * @brief RAII scoped zone (used via the EV_PROFILE_* macros). + */ +class EVENGINE_API ZoneScope { +public: + ZoneScope(const char* name, const char* module = nullptr) { + Profiler::zoneBegin(name, module); + } + ~ZoneScope() { Profiler::zoneEnd(); } + ZoneScope(const ZoneScope&) = delete; + ZoneScope& operator=(const ZoneScope&) = delete; +}; + +} // namespace eve::prof + +#define EV_PROFILE_CAT_I(a, b) a##b +#define EV_PROFILE_CAT(a, b) EV_PROFILE_CAT_I(a, b) + +/** @brief Profile the enclosing scope, auto-named from the current function. */ +#define EV_PROFILE_ZONE_FUNC() \ + ::eve::prof::ZoneScope EV_PROFILE_CAT(ev_zone_, __LINE__)(__func__) + +/** @brief Profile the enclosing scope with an explicit name. */ +#define EV_PROFILE_ZONE(name) \ + ::eve::prof::ZoneScope EV_PROFILE_CAT(ev_zone_, __LINE__)(name) + +/** @brief Profile the enclosing scope, tagged with a module for grouping. */ +#define EV_PROFILE_MODULE(module, name) \ + ::eve::prof::ZoneScope EV_PROFILE_CAT(ev_zone_, __LINE__)(name, module) + +#endif diff --git a/src/engine/common/ScriptCompiler.cpp b/src/engine/common/ScriptCompiler.cpp index 945bc8e9..ab7a8c4f 100644 --- a/src/engine/common/ScriptCompiler.cpp +++ b/src/engine/common/ScriptCompiler.cpp @@ -1,5 +1,6 @@ #include "common/ScriptCompiler.h" +#include "common/BindingContracts.h" #include "common/ScriptError.h" #include "common/ScriptModule.h" @@ -57,11 +58,11 @@ void appendUnique(std::vector& values, std::string value) { std::string maskComments(std::string_view source) { std::string result(source); - char quote = 0; - bool lineComment = false; + char quote = 0; + bool lineComment = false; bool blockComment = false; for (size_t i = 0; i < result.size(); ++i) { - const char c = result[i]; + const char c = result[i]; const char next = i + 1 < result.size() ? result[i + 1] : 0; if (lineComment) { if (c == '\n') @@ -240,21 +241,23 @@ std::string BindingContract::key() const { } void BindingContractRegistry::registerContract(BindingContract contract) { - contracts_[contract.key()] = std::move(contract); + ensureUnique(); + storage_->contracts[contract.key()] = std::move(contract); } bool BindingContractRegistry::unregisterContract(std::string_view key) { - return contracts_.erase(std::string(key)) != 0; + ensureUnique(); + return storage_->contracts.erase(std::string(key)) != 0; } const BindingContract* BindingContractRegistry::find(std::string_view key) const noexcept { - const auto found = contracts_.find(std::string(key)); - return found == contracts_.end() ? nullptr : &found->second; + const auto found = storage_->contracts.find(std::string(key)); + return found == storage_->contracts.end() ? nullptr : &found->second; } const BindingContract* BindingContractRegistry::findMethod(std::string_view method) const noexcept { const BindingContract* result = nullptr; - for (const auto& [_, contract] : contracts_) { + for (const auto& [_, contract] : storage_->contracts) { if (contract.method != method) continue; if (result != nullptr) return nullptr; result = &contract; @@ -264,14 +267,41 @@ const BindingContract* BindingContractRegistry::findMethod(std::string_view meth std::vector BindingContractRegistry::snapshot() const { std::vector result; - result.reserve(contracts_.size()); - for (const auto& [_, contract] : contracts_) result.push_back(contract); + result.reserve(storage_->contracts.size()); + for (const auto& [_, contract] : storage_->contracts) result.push_back(contract); std::sort(result.begin(), result.end(), [](const BindingContract& a, const BindingContract& b) { return a.key() < b.key(); }); return result; } +void BindingContractRegistry::ensureUnique() { + if (storage_.use_count() != 1) storage_ = std::make_shared(*storage_); +} + +namespace { +const BindingContractRegistry& generatedBindingContracts() { + static const BindingContractRegistry contracts = [] { + BindingContractRegistry result; + registerEngineBindingContracts(result); + return result; + }(); + return contracts; +} +} // namespace + ScriptCompiler::ScriptCompiler(ssq::VM& vm, ScriptModuleResolver& modules) : vm_(&vm), modules_(&modules) { + bindings_ = generatedBindingContracts(); + const SQInteger top = sq_gettop(vm_->getHandle()); + sq_pushroottable(vm_->getHandle()); + sq_pushnull(vm_->getHandle()); + while (SQ_SUCCEEDED(sq_next(vm_->getHandle(), -2))) { + if (sq_gettype(vm_->getHandle(), -2) == OT_STRING) { + const SQChar* name = nullptr; + if (SQ_SUCCEEDED(sq_getstring(vm_->getHandle(), -2, &name)) && name != nullptr) nativeGlobals_.insert(name); + } + sq_pop(vm_->getHandle(), 2); + } + sq_settop(vm_->getHandle(), top); { std::lock_guard lock(compilerRegistryMutex); compilerRegistry[vm_->getHandle()] = this; @@ -280,7 +310,8 @@ ScriptCompiler::ScriptCompiler(ssq::VM& vm, ScriptModuleResolver& modules) : vm_ vm_->getHandle(), [](HSQUIRRELVM, const SQChar* callee, SQInteger index, const SQChar** type, SQBool* nullable, const SQChar** unit, const SQChar** choices, SQUserPointer user) -> const SQChar* { - const auto& self = *static_cast(user); + const auto& self = *static_cast(user); + if (self.nativeGlobals_.find(callee) != self.nativeGlobals_.end()) return nullptr; const BindingContract* contract = self.bindings_.findMethod(callee); if (contract == nullptr || index < 0 || static_cast(index) >= contract->parameters.size()) return nullptr; @@ -478,23 +509,21 @@ std::vector ScriptCompiler::diagnostics(std::string_view canon bool ScriptCompiler::setSourceMap(std::string_view canonicalUri, ScriptSourceMap sourceMap) { const auto found = metadata_.find(std::string(canonicalUri)); if (found == metadata_.end()) return false; - sourceMap.canonicalUri = found->first; + sourceMap.canonicalUri = found->first; found->second.sourceMap = std::move(sourceMap); return true; } ScriptSourcePosition ScriptCompiler::toOriginalPosition(std::string_view source, ScriptSourcePosition generated) { - return mapRegisteredPosition(source, generated, - [](const ScriptSourceMap& map, ScriptSourcePosition position) { - return map.originalPosition(position); - }); + return mapRegisteredPosition(source, generated, [](const ScriptSourceMap& map, ScriptSourcePosition position) { + return map.originalPosition(position); + }); } ScriptSourcePosition ScriptCompiler::toGeneratedPosition(std::string_view source, ScriptSourcePosition original) { - return mapRegisteredPosition(source, original, - [](const ScriptSourceMap& map, ScriptSourcePosition position) { - return map.generatedPosition(position); - }); + return mapRegisteredPosition(source, original, [](const ScriptSourceMap& map, ScriptSourcePosition position) { + return map.generatedPosition(position); + }); } SQRESULT ScriptCompiler::compileBuffer(HSQUIRRELVM vm, const SQChar* source, SQInteger size, const SQChar* sourceName, diff --git a/src/engine/common/ScriptCompiler.h b/src/engine/common/ScriptCompiler.h index b2c95041..5d75d3c2 100644 --- a/src/engine/common/ScriptCompiler.h +++ b/src/engine/common/ScriptCompiler.h @@ -155,7 +155,12 @@ public: std::vector snapshot() const; private: - std::unordered_map contracts_; + struct Storage { + std::unordered_map contracts; + }; + + void ensureUnique(); + std::shared_ptr storage_ = std::make_shared(); }; /** @brief Unified Runtime compiler facade for metadata, diagnostics, and module preparation. */ @@ -208,6 +213,7 @@ private: ssq::VM* vm_; ScriptModuleResolver* modules_; BindingContractRegistry bindings_; + std::unordered_set nativeGlobals_; std::unordered_set annotations_; std::unordered_map metadata_; }; diff --git a/src/engine/common/ScriptModule.cpp b/src/engine/common/ScriptModule.cpp index 6a50c3d1..6609d5a1 100644 --- a/src/engine/common/ScriptModule.cpp +++ b/src/engine/common/ScriptModule.cpp @@ -255,42 +255,56 @@ struct ScriptModuleResolver::Impl { sq_resetobject(&module.exports); } - void reload(const std::string& canonical) { - const auto found = modules.find(canonical); - if (found == modules.end()) throw std::runtime_error("cannot reload unknown script module: " + canonical); - Module& previous = found->second; - const Module::State previousState = previous.state; - const std::vector previousEdges = dependencies[canonical]; - Module candidate; - sq_resetobject(&candidate.closure); - sq_resetobject(&candidate.exports); - try { - candidate.source = load(canonical); - candidate.state = Module::State::Compiling; - dependencies.erase(canonical); - previous.state = Module::State::Compiling; - const SQInteger top = sq_gettop(vm); - if (SQ_FAILED(sq_compilebuffer(vm, candidate.source.utf8Source.c_str(), - static_cast(candidate.source.utf8Source.size()), - canonical.c_str(), SQTrue))) { - sq_settop(vm, top); - throw std::runtime_error("failed to compile script module generation: " + canonical); + std::vector reloadAffected(const std::string& canonical) { + if (modules.find(canonical) == modules.end()) return {}; + + std::unordered_set affected{canonical}; + bool changed = true; + while (changed) { + changed = false; + for (const auto& [importer, edges] : dependencies) { + if (affected.count(importer) || modules.find(importer) == modules.end()) continue; + if (std::any_of(edges.begin(), edges.end(), [&](const auto& edge) { return affected.count(edge); })) { + affected.insert(importer); + changed = true; + } } - sq_getstackobj(vm, -1, &candidate.closure); - sq_addref(vm, &candidate.closure); - sq_settop(vm, top); - candidate.dependencies = dependencies[canonical]; - candidate.state = Module::State::Compiled; - previous.state = previousState; - instantiateModule(canonical, candidate); + } + + std::vector order(affected.begin(), affected.end()); + std::sort(order.begin(), order.end()); + const auto previousEdges = dependencies; + std::unordered_set previousKeys; + for (const auto& [uri, _] : modules) previousKeys.insert(uri); + + std::unordered_map previous; + for (const std::string& uri : order) { + auto found = modules.find(uri); + if (found == modules.end()) continue; + previous.emplace(uri, std::move(found->second)); + modules.erase(found); + dependencies.erase(uri); + } + + try { + for (const std::string& uri : order) ensureCompiled("game:/__hot_reload__.nut", uri); + for (const std::string& uri : order) instantiate(uri); } catch (...) { - previous.state = previousState; - dependencies[canonical] = previousEdges; - release(candidate); + for (auto it = modules.begin(); it != modules.end();) { + if (affected.count(it->first) || !previousKeys.count(it->first)) { + release(it->second); + it = modules.erase(it); + } else { + ++it; + } + } + for (auto& [uri, module] : previous) modules.emplace(uri, std::move(module)); + dependencies = previousEdges; throw; } - release(previous); - previous = std::move(candidate); + + for (auto& [_, module] : previous) release(module); + return order; } SQVM* vm = nullptr; @@ -339,7 +353,7 @@ void ScriptModuleResolver::prepareDependencies(std::string_view importerUri) { enum class Visit { Visiting, Ready }; std::unordered_map visits; std::vector stack; - const auto verify = [&](const auto& self, const std::string& uri) -> void { + const auto verify = [&](const auto& self, const std::string& uri) -> void { if (const auto seen = visits.find(uri); seen != visits.end()) { if (seen->second == Visit::Ready) return; std::string cycle; @@ -401,7 +415,14 @@ void ScriptModuleResolver::invalidate(std::string_view canonicalUri) { } } -void ScriptModuleResolver::reload(std::string_view canonicalUri) { impl_->reload(std::string(canonicalUri)); } +void ScriptModuleResolver::reload(std::string_view canonicalUri) { + if (impl_->reloadAffected(std::string(canonicalUri)).empty()) + throw std::runtime_error("cannot reload unknown script module: " + std::string(canonicalUri)); +} + +std::vector ScriptModuleResolver::reloadAffected(std::string_view canonicalUri) { + return impl_->reloadAffected(std::string(canonicalUri)); +} std::vector ScriptModuleResolver::dependencies(std::string_view importerUri) const { const auto found = impl_->dependencies.find(std::string(importerUri)); diff --git a/src/engine/common/ScriptModule.h b/src/engine/common/ScriptModule.h index 4d83bfd8..e63eb9ec 100644 --- a/src/engine/common/ScriptModule.h +++ b/src/engine/common/ScriptModule.h @@ -83,6 +83,8 @@ public: void invalidate(std::string_view canonicalUri); /** @brief Transactionally reloads one module, retaining the prior generation on failure. */ void reload(std::string_view canonicalUri); + /** @brief Transactionally reloads a module and every cached module that imports it. */ + std::vector reloadAffected(std::string_view canonicalUri); /** @brief Returns canonical direct dependencies for an importer. */ std::vector dependencies(std::string_view importerUri) const; /** @brief Returns canonical importers that directly depend on a module. */ diff --git a/src/modules/CMakeLists.txt b/src/modules/CMakeLists.txt index 06314678..9a398650 100644 --- a/src/modules/CMakeLists.txt +++ b/src/modules/CMakeLists.txt @@ -25,6 +25,14 @@ if(NOT _has_scene) CACHE INTERNAL "Exact basenames excluded from module source scan") endif() +# editor/EditorPcgGraph.cpp is the optional bridge to procgen PointGraph. +eve_module_enabled(procgen _has_procgen) +if(NOT _has_procgen) + set(EVENGINE_EXCLUDED_MODULE_FILES + "${EVENGINE_EXCLUDED_MODULE_FILES};EditorPcgGraph.cpp" + CACHE INTERNAL "Exact basenames excluded from module source scan") +endif() + # --- create every enabled module ------------------------------------------- foreach(_mod IN LISTS EVE_ENABLED_MODULES) diff --git a/src/modules/animation/Animation.cpp b/src/modules/animation/Animation.cpp index 69bac4c8..2586fcd1 100644 --- a/src/modules/animation/Animation.cpp +++ b/src/modules/animation/Animation.cpp @@ -28,6 +28,7 @@ #include "animation/SpriteSheet.h" #include "common/Exception.h" +#include "common/Profile.h" #include "graphics/Graphics.h" #include "graphics/Texture.h" #include "graphics/Mesh.h" @@ -490,6 +491,7 @@ eve::Result Animation::advance(const eve::SimulationStep& step) { } void Animation::update(float dt) { + EV_PROFILE_MODULE("animation", "Animation::update"); auto duration = eve::Duration::fromSeconds(dt); if (!duration) { duration.ignore("legacy animation update received an invalid duration"); diff --git a/src/modules/attributes/AttributeProjection.h b/src/modules/attributes/AttributeProjection.h index d8a17d56..4589181b 100644 --- a/src/modules/attributes/AttributeProjection.h +++ b/src/modules/attributes/AttributeProjection.h @@ -82,7 +82,7 @@ public: /** @brief Add to a canonical base value and advance the projection revision. */ [[nodiscard]] Result modifyBase(std::string_view attribute, double delta); - /** @brief Read a deterministic final value, or the supplied fallback when absent. */ + /** @brief Read a deterministic final value, or the supplied default when absent. */ [[nodiscard]] Result getFinal(std::string_view attribute, double fallback = 0.0) const; diff --git a/src/modules/attributes/AttributeSet.h b/src/modules/attributes/AttributeSet.h index eb91b10c..d2278445 100644 --- a/src/modules/attributes/AttributeSet.h +++ b/src/modules/attributes/AttributeSet.h @@ -33,7 +33,7 @@ enum class AttributeOperation : std::uint8_t { * @brief Stable spelling of a built-in attribute operation. * @ownership The returned string is a borrowed, process-static literal; the * caller must not free or retain it as mutable storage. - * @nullable Never null for a valid enum value; the fallback spelling is also + * @nullable Never null for a valid enum value; the unknown-value spelling is also * a process-static literal for an invalid value. * @lifetime Valid for the process lifetime. * @thread Thread-safe because the function reads no mutable state. diff --git a/src/modules/audio/Audio.cpp b/src/modules/audio/Audio.cpp index b9ceccd2..83b51b4a 100644 --- a/src/modules/audio/Audio.cpp +++ b/src/modules/audio/Audio.cpp @@ -3,6 +3,7 @@ #include "AudioCapabilities.h" #include "common/Exception.h" +#include "common/Profile.h" #include "common/StartupTiming.h" #include "sound/Decoder.h" #include "sound/Sound.h" @@ -174,6 +175,7 @@ void Audio::setOrientation(float fx, float fy, float fz, float ux, float uy, flo } void Audio::pump() { + EV_PROFILE_MODULE("audio", "Audio::pump"); // Same guarantee as workerMain: destruction removes the source under this // mutex, so the pointer stays valid for the whole iteration. std::lock_guard lock(mutex); diff --git a/src/modules/crowd/Crowd.cpp b/src/modules/crowd/Crowd.cpp index e7e85137..e1a4a64d 100644 --- a/src/modules/crowd/Crowd.cpp +++ b/src/modules/crowd/Crowd.cpp @@ -1,6 +1,7 @@ #include "crowd/Crowd.h" #include "common/Exception.h" +#include "common/Profile.h" #include @@ -663,6 +664,7 @@ void Crowd::setResolveOverlaps(bool enable) { impl_->resolveOverlaps = enable; } void Crowd::setClampToField(bool enable) { impl_->clampToField = enable; } void Crowd::step(float dt) { + EV_PROFILE_MODULE("crowd", "Crowd::step"); if (dt <= 0.f) return; impl_->simTime += dt; impl_->rebuildGrid(); diff --git a/src/modules/decision/Condition.h b/src/modules/decision/Condition.h index ee163178..66b654fd 100644 --- a/src/modules/decision/Condition.h +++ b/src/modules/decision/Condition.h @@ -92,7 +92,7 @@ enum class ConditionReasonCode : std::uint32_t { /** * @brief Return the stable lowercase spelling of a condition reason code. * @ownership Borrowed process-static text; callers must not free or modify it. - * @nullable Never null; unknown numeric values use the stable fallback literal. + * @nullable Never null; unknown numeric values use the stable unknown-value literal. * @lifetime Process lifetime. * @thread Thread-safe and reentrant; no mutable state or callbacks are used. */ @@ -101,7 +101,7 @@ enum class ConditionReasonCode : std::uint32_t { /** * @brief Return the stable lowercase spelling of a condition node kind. * @ownership Borrowed process-static text; callers must not free or modify it. - * @nullable Never null; unknown numeric values use the stable fallback literal. + * @nullable Never null; unknown numeric values use the stable unknown-value literal. * @lifetime Process lifetime. * @thread Thread-safe and reentrant; no mutable state or callbacks are used. */ @@ -110,7 +110,7 @@ enum class ConditionReasonCode : std::uint32_t { /** * @brief Return the stable lowercase spelling of a comparison operator. * @ownership Borrowed process-static text; callers must not free or modify it. - * @nullable Never null; unknown numeric values use the stable fallback literal. + * @nullable Never null; unknown numeric values use the stable unknown-value literal. * @lifetime Process lifetime. * @thread Thread-safe and reentrant; no mutable state or callbacks are used. */ diff --git a/src/modules/editor/EditorGraph.cpp b/src/modules/editor/EditorGraph.cpp index 7e25df85..b45eafcf 100644 --- a/src/modules/editor/EditorGraph.cpp +++ b/src/modules/editor/EditorGraph.cpp @@ -80,10 +80,23 @@ EditorResult GraphDocument::setNodeProperties(const GraphNodeId& node, Edi return EditorResult::applied(); } +EditorResult GraphDocument::setParameters(EditorValue parameters) { + if (parameters.type() != EditorValue::Type::Object) + return graphError(EditorStatus::Rejected, "editor.graph.invalid-parameters", + "Graph parameters must be an object"); + if (!parameters.withinLimits(4, 4096, 256 * 1024)) + return graphError(EditorStatus::Rejected, "editor.graph.parameters-too-large", + "Graph parameters exceed editor document limits"); + parameters_ = std::move(parameters); + ++revision_; + return EditorResult::applied(); +} + GraphDocumentData GraphDocument::snapshot(std::string domain) const { GraphDocumentData result; result.domain = std::move(domain); result.revision = revision_; + result.parameters = parameters_; for (const auto& [id, node] : nodes_) { (void)id; result.nodes.push_back(node); diff --git a/src/modules/editor/EditorGraph.h b/src/modules/editor/EditorGraph.h index cfcd8d2f..9a1a0c8b 100644 --- a/src/modules/editor/EditorGraph.h +++ b/src/modules/editor/EditorGraph.h @@ -65,6 +65,8 @@ public: EditorResult disconnect(const StableId& edge); /** @brief Replace one node's structured property object. */ EditorResult setNodeProperties(const GraphNodeId& node, EditorValue properties); + /** @brief Replace domain-specific graph parameters stored with the document. */ + EditorResult setParameters(EditorValue parameters); /** @brief Capture deterministic graph values for compilation/persistence. */ GraphDocumentData snapshot(std::string domain) const; /** @brief Return the current graph edit revision. */ @@ -75,6 +77,7 @@ public: private: std::map nodes_; std::map edges_; + EditorValue parameters_; Revision revision_ = 0; }; diff --git a/src/modules/editor/EditorPcgGraph.cpp b/src/modules/editor/EditorPcgGraph.cpp new file mode 100644 index 00000000..d1f285ee --- /dev/null +++ b/src/modules/editor/EditorPcgGraph.cpp @@ -0,0 +1,409 @@ +#include "editor/EditorPcgGraph.h" + +#include "procgen/PointGraph.h" + +#include +#include +#include +#include +#include +#include + +namespace eve::editor { +namespace { + +template +EditorResult pcgGraphError(const char* rule, std::string message) { + return EditorResult::error(EditorStatus::Rejected, RuleId(rule), std::move(message)); +} + +const GraphNodeRecord* findNode(const GraphDocumentData& graph, const GraphNodeId& id) { + const auto found = std::find_if(graph.nodes.begin(), graph.nodes.end(), + [&](const GraphNodeRecord& node) { return node.id == id; }); + return found == graph.nodes.end() ? nullptr : &*found; +} + +const GraphPinRecord* findPin(const GraphDocumentData& graph, const GraphPinId& id) { + for (const auto& node : graph.nodes) { + const auto found = std::find_if(node.pins.begin(), node.pins.end(), + [&](const GraphPinRecord& pin) { return pin.id == id; }); + if (found != node.pins.end()) return &*found; + } + return nullptr; +} + +int inputIndex(const GraphNodeRecord& node, const GraphPinId& pin) { + int index = 0; + for (const auto& candidate : node.pins) { + if (candidate.direction != GraphPinDirection::Input) continue; + if (candidate.id == pin) return index; + ++index; + } + return -1; +} + +EditorValue defaultValue(const std::string& kind, const std::string& encoded) { + if (kind == "bool") return EditorValue(encoded == "true"); + if (kind == "int") return EditorValue(static_cast(std::stoll(encoded))); + if (kind == "float") return EditorValue(std::stod(encoded)); + return EditorValue(encoded); +} + +} // namespace + +GraphConnectionDecision PcgPointGraphDomain::canConnect(const GraphPinRecord& from, + const GraphPinRecord& to) const { + GraphConnectionDecision decision; + decision.allowed = from.direction == GraphPinDirection::Output && + to.direction == GraphPinDirection::Input && from.type == "point" && + to.type == "point" && from.node != to.node; + if (!decision.allowed) + decision.diagnostics.push_back( + {RuleId("editor.pcg.invalid-connection"), DiagnosticSeverity::Error, + "PCG graph connections require point output-to-input pins on distinct nodes"}); + return decision; +} + +EditorResult PcgPointGraphDomain::makeNode(const GraphNodeId& id, + const std::string& operation) const { + if (id.empty() || procgen::PointGraph::getOperationInputCount(operation) < 0) + return pcgGraphError("editor.pcg.unknown-operation", + "Unknown PointGraph operation"); + GraphNodeRecord node; + node.id = id; + node.type = operation; + EditorValue::Object properties; + const int parameterCount = procgen::PointGraph::getOperationParamCount(operation); + for (int i = 0; i < parameterCount; ++i) { + const std::string key = procgen::PointGraph::getOperationParamKey(operation, i); + properties[key] = defaultValue(procgen::PointGraph::getOperationParamKind(operation, i), + procgen::PointGraph::getOperationParamDefault(operation, i)); + } + if (operation == "subgraph") { + properties["definition"] = EditorValue(""); + properties["inputNode"] = EditorValue("input"); + properties["outputNode"] = EditorValue("output"); + } + node.properties = EditorValue(std::move(properties)); + const int inputs = procgen::PointGraph::getOperationInputCount(operation); + for (int i = 0; i < inputs; ++i) + node.pins.push_back({GraphPinId(id.value() + ".in" + std::to_string(i)), id, "point", + GraphPinDirection::Input}); + node.pins.push_back( + {GraphPinId(id.value() + ".out"), id, "point", GraphPinDirection::Output}); + return EditorResult::applied(std::move(node)); +} + +PcgGraphMigrationResult PcgPointGraphDomain::migrate(const GraphDocumentData& graph) const { + PcgGraphMigrationResult result; + result.fromVersion = graph.schemaVersion; + result.graph = graph; + if (graph.domain != domain()) { + result.status = EditorStatus::Rejected; + result.diagnostics.push_back({RuleId("editor.pcg.wrong-domain"), + DiagnosticSeverity::Error, + "Graph is not a procgen.point document"}); + return result; + } + if (graph.schemaVersion == 1) { + result.status = EditorStatus::NoOp; + return result; + } + if (graph.schemaVersion != 0) { + result.status = EditorStatus::Unsupported; + result.diagnostics.push_back( + {RuleId("editor.pcg.unsupported-schema"), DiagnosticSeverity::Error, + "Unsupported procgen.point graph schemaVersion: " + + std::to_string(graph.schemaVersion)}); + return result; + } + + std::unordered_map pinRemap; + std::vector migratedNodes; + migratedNodes.reserve(graph.nodes.size()); + for (const auto& legacy : graph.nodes) { + auto canonical = makeNode(legacy.id, legacy.type); + if (!canonical.value) { + result.status = EditorStatus::Rejected; + result.diagnostics.push_back( + {RuleId("editor.pcg.migration-unknown-operation"), DiagnosticSeverity::Error, + "Cannot migrate unknown PCG operation: " + legacy.type}); + return result; + } + auto* defaults = canonical.value->properties.getIf(); + const auto* properties = legacy.properties.getIf(); + if (legacy.properties.type() != EditorValue::Type::Null && !properties) { + result.status = EditorStatus::Rejected; + result.diagnostics.push_back( + {RuleId("editor.pcg.migration-invalid-properties"), DiagnosticSeverity::Error, + "Legacy PCG node properties must be an object: " + legacy.id.value()}); + return result; + } + if (properties) + for (const auto& [key, value] : *properties) (*defaults)[key] = value; + + int input = 0; + int output = 0; + for (const auto& pin : legacy.pins) { + const int ordinal = pin.direction == GraphPinDirection::Input ? input++ : output++; + int canonicalOrdinal = 0; + const auto replacement = std::find_if( + canonical.value->pins.begin(), canonical.value->pins.end(), + [&](const GraphPinRecord& candidate) { + if (candidate.direction != pin.direction) return false; + return canonicalOrdinal++ == ordinal; + }); + if (replacement == canonical.value->pins.end()) continue; + if (!pinRemap.emplace(pin.id.value(), replacement->id).second) { + result.status = EditorStatus::Rejected; + result.diagnostics.push_back( + {RuleId("editor.pcg.migration-duplicate-pin"), DiagnosticSeverity::Error, + "Legacy PCG graph contains duplicate pin id: " + pin.id.value()}); + return result; + } + } + migratedNodes.push_back(std::move(*canonical.value)); + } + result.graph.nodes = std::move(migratedNodes); + for (auto& edge : result.graph.edges) { + const auto from = pinRemap.find(edge.from.value()); + const auto to = pinRemap.find(edge.to.value()); + if (from != pinRemap.end()) edge.from = from->second; + if (to != pinRemap.end()) edge.to = to->second; + } + result.graph.schemaVersion = 1; + result.status = EditorStatus::Applied; + result.diagnostics.push_back( + {RuleId("editor.pcg.migrated-v0-v1"), DiagnosticSeverity::Info, + "Migrated procgen.point graph schemaVersion 0 to 1"}); + return result; +} + +PcgGraphCompileResult PcgPointGraphDomain::compile(const GraphDocumentData& graph) const { + PcgGraphCompileResult result; + result.documentRevision = graph.revision; + const auto migration = migrate(graph); + if (migration.status != EditorStatus::Applied && migration.status != EditorStatus::NoOp) { + result.status = migration.status; + result.diagnostics = migration.diagnostics; + return result; + } + result.diagnostics = migration.diagnostics; + const auto& source = migration.graph; + procgen::PointGraph compiled; + for (const auto& node : source.nodes) { + if (!compiled.addNode(node.id.value(), node.type)) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back({RuleId("editor.pcg.invalid-node"), DiagnosticSeverity::Error, + "Unknown or duplicated PCG node: " + node.id.value()}); + return result; + } + const auto* properties = node.properties.getIf(); + if (!properties) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId("editor.pcg.invalid-properties"), DiagnosticSeverity::Error, + "PCG node properties must be an object: " + node.id.value()}); + return result; + } + for (const auto& [key, value] : *properties) { + const bool subgraphProperty = + node.type == "subgraph" && + (key == "definition" || key == "inputNode" || key == "outputNode"); + if (subgraphProperty) continue; + int parameterIndex = -1; + const int parameterCount = procgen::PointGraph::getOperationParamCount(node.type); + for (int index = 0; index < parameterCount; ++index) + if (procgen::PointGraph::getOperationParamKey(node.type, index) == key) { + parameterIndex = index; + break; + } + const std::string kind = parameterIndex >= 0 + ? procgen::PointGraph::getOperationParamKind( + node.type, parameterIndex) + : std::string(); + bool applied = false; + if (kind == "bool") { + if (const auto* boolean = value.getIf()) + applied = compiled.setNodeInt(node.id.value(), key, *boolean ? 1 : 0); + } else if (kind == "int") { + if (const auto* integer = value.getIf(); + integer && *integer >= std::numeric_limits::min() && + *integer <= std::numeric_limits::max()) + applied = compiled.setNodeInt(node.id.value(), key, int(*integer)); + } else if (kind == "float") { + if (const auto* number = value.getIf(); + number && std::isfinite(*number) && + std::abs(*number) <= std::numeric_limits::max()) + applied = compiled.setNodeFloat(node.id.value(), key, float(*number)); + } else if (kind == "string") { + if (const auto* text = value.getIf()) + applied = compiled.setNodeString(node.id.value(), key, *text); + } + if (!applied) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId(parameterIndex < 0 ? "editor.pcg.unknown-property" + : "editor.pcg.property-type-mismatch"), + DiagnosticSeverity::Error, + "Invalid PCG property " + node.id.value() + "." + key}); + return result; + } + } + if (node.type == "subgraph") { + const auto definition = properties->find("definition"); + const auto inputNode = properties->find("inputNode"); + const auto outputNode = properties->find("outputNode"); + const auto* definitionText = + definition == properties->end() ? nullptr : definition->second.getIf(); + const auto* inputText = + inputNode == properties->end() ? nullptr : inputNode->second.getIf(); + const auto* outputText = + outputNode == properties->end() ? nullptr : outputNode->second.getIf(); + procgen::PointGraph nested; + if (!definitionText || !inputText || !outputText || definitionText->empty() || + !nested.deserializeDefinition(*definitionText) || + !compiled.setNodeSubgraph(node.id.value(), &nested, *inputText, *outputText)) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId("editor.pcg.invalid-subgraph"), DiagnosticSeverity::Error, + "PCG subgraph node requires a valid definition and input/output node ids"}); + return result; + } + } + } + if (source.parameters.type() != EditorValue::Type::Null) { + const auto* parameters = source.parameters.getIf(); + if (!parameters) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId("editor.pcg.invalid-parameters"), DiagnosticSeverity::Error, + "PCG graph parameters must be an object"}); + return result; + } + for (const auto& [name, value] : *parameters) { + const auto* binding = value.getIf(); + const auto node = binding ? binding->find("node") : EditorValue::Object::const_iterator{}; + const auto key = binding ? binding->find("key") : EditorValue::Object::const_iterator{}; + const auto* nodeId = binding && node != binding->end() + ? node->second.getIf() + : nullptr; + const auto* parameterKey = binding && key != binding->end() + ? key->second.getIf() + : nullptr; + if (!nodeId || !parameterKey || + !compiled.exposeParameter(name, *nodeId, *parameterKey)) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId("editor.pcg.invalid-parameter-binding"), DiagnosticSeverity::Error, + "Invalid PCG graph parameter binding: " + name}); + return result; + } + } + } + std::unordered_map connectedInputs; + for (const auto& edge : source.edges) { + const GraphPinRecord* from = findPin(source, edge.from); + const GraphPinRecord* to = findPin(source, edge.to); + const GraphNodeRecord* toNode = to ? findNode(source, to->node) : nullptr; + const int slot = toNode && to ? inputIndex(*toNode, to->id) : -1; + const std::string inputKey = to ? to->id.value() : std::string(); + if (!inputKey.empty() && connectedInputs.find(inputKey) != connectedInputs.end()) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId("editor.pcg.duplicate-input"), DiagnosticSeverity::Error, + "PCG input pin has more than one incoming edge: " + inputKey}); + return result; + } + if (!from || !to || !toNode || !canConnect(*from, *to).allowed || slot < 0 || + !compiled.connect(from->node.value(), to->node.value(), slot)) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back({RuleId("editor.pcg.invalid-edge"), DiagnosticSeverity::Error, + "Invalid PCG graph edge: " + edge.id.value()}); + return result; + } + connectedInputs.emplace(inputKey, edge.id); + } + result.definition = compiled.serializeDefinition(); + procgen::PointGraph structuralCheck; + if (!structuralCheck.deserializeDefinition(result.definition)) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back({RuleId("editor.pcg.cycle"), DiagnosticSeverity::Error, + structuralCheck.getError()}); + result.definition.clear(); + return result; + } + result.status = EditorStatus::Applied; + return result; +} + +PcgGraphPreviewResult PcgPointGraphDomain::preview(const GraphDocumentData& graph, + const std::string& inputNode, + procgen::PointSet* previewInput, + const std::string& outputNode, + int nodeBudget, int pointBudget) const { + PcgGraphPreviewResult result; + result.documentRevision = graph.revision; + const auto compiled = compile(graph); + result.diagnostics = compiled.diagnostics; + if (compiled.status != EditorStatus::Applied) { + result.status = compiled.status; + return result; + } + if (!previewInput || inputNode.empty() || outputNode.empty()) { + result.status = EditorStatus::Rejected; + result.diagnostics.push_back( + {RuleId("editor.pcg.preview-invalid-input"), DiagnosticSeverity::Error, + "PCG preview requires input/output node ids and point input"}); + return result; + } + procgen::PointGraph runtime; + if (!runtime.deserializeDefinition(compiled.definition)) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId("editor.pcg.preview-bind-failed"), DiagnosticSeverity::Error, + "PCG preview definition could not be loaded"}); + return result; + } + runtime.setExecutionNodeBudget(nodeBudget); + runtime.setMaxNodeOutputPoints(pointBudget); + if (!runtime.setNodePoints(inputNode, previewInput)) { + result.status = EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId("editor.pcg.preview-bind-failed"), DiagnosticSeverity::Error, + runtime.getError().empty() ? "PCG preview input node is invalid: " + inputNode + : runtime.getError()}); + return result; + } + std::unique_ptr output(runtime.execute(outputNode)); + if (!output) { + result.status = runtime.wasCancelled() ? EditorStatus::Cancelled : EditorStatus::Failed; + result.diagnostics.push_back( + {RuleId(runtime.wasCancelled() ? "editor.pcg.preview-cancelled" + : "editor.pcg.preview-execution-failed"), + runtime.wasCancelled() ? DiagnosticSeverity::Info : DiagnosticSeverity::Error, + runtime.getError()}); + return result; + } + result.outputCount = output->getCount(); + result.metrics.reserve(size_t(runtime.getMetricCount())); + for (int index = 0; index < runtime.getMetricCount(); ++index) { + PcgGraphPreviewMetric metric; + metric.nodeId = runtime.getMetricNodeId(index); + metric.outputCount = runtime.getMetricOutputCount(index); + metric.milliseconds = runtime.getMetricMilliseconds(index); + metric.cacheHit = runtime.isMetricCacheHit(index); + metric.minX = runtime.getMetricMinX(index); + metric.minY = runtime.getMetricMinY(index); + metric.minZ = runtime.getMetricMinZ(index); + metric.maxX = runtime.getMetricMaxX(index); + metric.maxY = runtime.getMetricMaxY(index); + metric.maxZ = runtime.getMetricMaxZ(index); + metric.averageDensity = runtime.getMetricAverageDensity(index); + result.metrics.push_back(std::move(metric)); + } + result.status = EditorStatus::Applied; + return result; +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorPcgGraph.h b/src/modules/editor/EditorPcgGraph.h new file mode 100644 index 00000000..8cc8c11b --- /dev/null +++ b/src/modules/editor/EditorPcgGraph.h @@ -0,0 +1,89 @@ +#pragma once + +#include "editor/EditorGraph.h" + +#include +#include + +namespace eve::procgen { +class PointSet; +} + +namespace eve::editor { + +/** @brief Result of compiling a generic editor graph into a PointGraph asset definition. */ +struct PcgGraphCompileResult { + EditorStatus status = EditorStatus::Failed; + Revision documentRevision = 0; + std::string definition; + std::vector diagnostics; +}; + +/** @brief Transactional result of upgrading an editor PCG graph to the current schema. */ +struct PcgGraphMigrationResult { + EditorStatus status = EditorStatus::Failed; + std::uint32_t fromVersion = 0; + std::uint32_t toVersion = 1; + GraphDocumentData graph; + std::vector diagnostics; +}; + +/** @brief One node's isolated PointGraph preview telemetry for graph overlays. */ +struct PcgGraphPreviewMetric { + std::string nodeId; + int outputCount = 0; + float milliseconds = 0.f; + bool cacheHit = false; + float minX = 0.f; + float minY = 0.f; + float minZ = 0.f; + float maxX = 0.f; + float maxY = 0.f; + float maxZ = 0.f; + float averageDensity = 0.f; +}; + +/** @brief Revision-tagged result of compiling and executing an isolated editor preview. */ +struct PcgGraphPreviewResult { + EditorStatus status = EditorStatus::Failed; + Revision documentRevision = 0; + int outputCount = 0; + std::vector metrics; + std::vector diagnostics; +}; + +/** + * @brief `procgen.point` editor graph domain backed by procgen::PointGraph schema. + * + * This optional bridge is compiled only when both editor and procgen modules + * are enabled. It creates typed pins/default properties from runtime operation + * reflection and compiles the neutral GraphDocument into a versioned asset. + */ +class PcgPointGraphDomain final : public IGraphDomainProvider { +public: + std::string domain() const override { return "procgen.point"; } + GraphConnectionDecision canConnect(const GraphPinRecord& from, + const GraphPinRecord& to) const override; + /** @brief Construct a generic editor node from one reflected PointGraph operation. */ + EditorResult makeNode(const GraphNodeId& id, + const std::string& operation) const; + /** @brief Upgrade a legacy graph without mutating the source document. */ + PcgGraphMigrationResult migrate(const GraphDocumentData& graph) const; + /** @brief Compile topology and scalar properties into a reusable PointGraph definition. */ + PcgGraphCompileResult compile(const GraphDocumentData& graph) const; + /** + * @brief Compile and execute an isolated point-input preview for editor visualization. + * @param graph Immutable graph document snapshot. + * @param inputNode PointGraph input node receiving previewInput. + * @param previewInput Non-owning point data used only for this execution. + * @param outputNode Node whose evaluated output determines outputCount. + * @param nodeBudget Maximum newly evaluated nodes, or zero for unlimited. + * @param pointBudget Maximum points cached by one node, or zero for unlimited. + */ + PcgGraphPreviewResult preview(const GraphDocumentData& graph, const std::string& inputNode, + procgen::PointSet* previewInput, + const std::string& outputNode, int nodeBudget = 0, + int pointBudget = 100000) const; +}; + +} // namespace eve::editor diff --git a/src/modules/effects/Effects.cpp b/src/modules/effects/Effects.cpp index 74dd84a2..eca07f7f 100644 --- a/src/modules/effects/Effects.cpp +++ b/src/modules/effects/Effects.cpp @@ -171,7 +171,9 @@ void Effects::expose(ssq::Table& table) { "effect must not be null", "effect")); return eve::script::projectResult(vm, value->removeTag(tag)); }); - effect.addFunc("hasTag", &Effect::hasTag); + effect.addFunc("hasTag", [](Effect* value, const std::string& tag) { + return value && value->hasTag(tag); + }); effect.addFunc("tagCount", &Effect::tagCount); effect.addFunc("tagAt", &Effect::tagAt); diff --git a/src/modules/graphics/Graphics.cpp b/src/modules/graphics/Graphics.cpp index f88c355e..fd0b6adf 100644 --- a/src/modules/graphics/Graphics.cpp +++ b/src/modules/graphics/Graphics.cpp @@ -123,6 +123,14 @@ void setCanvasScript(Graphics *gfx, ssq::Object obj) { gfx->setCanvas(obj.toPtrUnsafe()); } +void setShaderScript(Graphics *gfx, ssq::Object obj) { + if (obj.isNull()) { + gfx->setShader(); + return; + } + gfx->setShader(obj.toPtrUnsafe()); +} + } // namespace Graphics::Graphics() { @@ -861,7 +869,7 @@ void Graphics::expose(ssq::Class& cls) { cls.addFunc("newWater", &Graphics::newWater); cls.addFunc("newShaderFromSpvFile", static_cast(&Graphics::newShaderFromSpvFile)); - cls.addFunc("setShader", static_cast(&Graphics::setShader)); + cls.addFunc("setShader", std::function(setShaderScript)); cls.addFunc("getShader", &Graphics::getShader); cls.addFunc("render3D", &Graphics::render3D); cls.addFunc("begin3DFrame", &Graphics::begin3DFrame); diff --git a/src/modules/graphics/vulkan/Graphics.cpp b/src/modules/graphics/vulkan/Graphics.cpp index 4f9df60d..d527d892 100644 --- a/src/modules/graphics/vulkan/Graphics.cpp +++ b/src/modules/graphics/vulkan/Graphics.cpp @@ -61,6 +61,8 @@ Graphics::~Graphics() { detachGraphicsArtifactProvider(this); if (!initialized) return; device->waitIdle(); + if (gpuQueryPool_) device->destroyQueryPool(gpuQueryPool_); + gpuQueryPool_ = nullptr; // Pipeline objects hold raw Shader* owned by ownedShaders. Drop them // first so their destructors do not see freed shader pointers. pipelineAA_.reset(); @@ -386,6 +388,7 @@ void Graphics::initHeadless(int width, int height) { } void Graphics::initWithWindow(void *nativeWindow) { + eve::cap::provide(this); StartupStage initStage("graphics: initWithWindow (total)"); if (initialized) { // Window module may destroy/recreate the SDL window (tests, setWindowSettings). @@ -679,7 +682,10 @@ bool Graphics::beginPresentCommandBuffer() { for (int attempt = 0; attempt < 3; ++attempt) { if (!rebuildSwapchainIfNeeded()) return false; presentRecording = presentModel.begin(); - if (presentRecording) return true; + if (presentRecording) { + writeGpuTimestampBegin(); + return true; + } // Acquire failed without beginning the CB (see Present::begin). Rebuild once. if (presentModel.needs_recreate) { presentModel.needs_recreate = false; @@ -791,5 +797,51 @@ void Graphics::present() { void Graphics::draw(eve::graphics::Graphics *, const glm::mat4 &) const {} void Graphics::draw(Canvas *, const glm::mat4 &) const {} +// ---- GPU frame timing ------------------------------------------------------ + +void Graphics::initGpuTiming() { + if (gpuQueryPool_) return; + try { + timestampPeriod_ = device.physical_device.properties.limits.timestampPeriod; + vk::QueryPoolCreateInfo ci; + ci.queryType = vk::QueryType::eTimestamp; + ci.queryCount = 2; + gpuQueryPool_ = device->createQueryPool(ci); + gpuTimingReady_ = true; + } catch (...) { + gpuTimingReady_ = false; + gpuQueryPool_ = nullptr; + } +} + +void Graphics::writeGpuTimestampBegin() { + if (!gpuTimingReady_ || !presentRecording) return; + vk::CommandBuffer cb = presentRecording.commandBuffer(); + cb.resetQueryPool(gpuQueryPool_, 0, 2); + cb.writeTimestamp(vk::PipelineStageFlagBits::eTopOfPipe, gpuQueryPool_, 0); +} + +void Graphics::writeGpuTimestampEnd() { + if (!gpuTimingReady_ || !presentRecording) return; + vk::CommandBuffer cb = presentRecording.commandBuffer(); + cb.writeTimestamp(vk::PipelineStageFlagBits::eBottomOfPipe, gpuQueryPool_, 1); +} + +void Graphics::readGpuFrameTiming() { + if (!gpuTimingReady_ || !gpuQueryPool_) return; + // drawFrame() waits on the in-flight fence, so results are valid here. + std::array ts{}; + const auto r = device->getQueryPoolResults( + gpuQueryPool_, 0, 2, sizeof(ts), ts.data(), sizeof(uint64_t), + vk::QueryResultFlagBits::e64 | vk::QueryResultFlagBits::eWait); + if (r == vk::Result::eSuccess && ts[1] > ts[0]) + gpuFrameMs_ = float(double(ts[1] - ts[0]) * double(timestampPeriod_) / 1'000'000.0); + else + gpuFrameMs_ = 0.f; +} + +bool Graphics::gpuTimingAvailable() const { return gpuTimingReady_ && gpuFrameMs_ > 0.f; } + +float Graphics::gpuFrameMs() const { return gpuFrameMs_; } } // namespace eve::graphics::vulkan diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index 8dc5a91b..fd608b94 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -20,6 +20,8 @@ #include "graphics/vulkan/GpuDriven.h" #include "graphics/vulkan/FrameArena.h" #include "graphics/vulkan/ComputePass.h" +#include "common/Capability.h" +#include "common/GpuTimer.h" namespace eve::graphics::vulkan { @@ -216,7 +218,7 @@ struct GpuShader { Shader *owner = nullptr; }; -class Graphics final : public eve::graphics::Graphics { +class Graphics final : public eve::graphics::Graphics, public eve::service::IGpuTimer { public: // Keep the base draw(Drawable*, mat4) overload visible alongside the // canvas composite overloads below. @@ -224,6 +226,19 @@ public: ~Graphics() override; + // ---- GPU frame timing (eve::service::IGpuTimer) ---------------------- + /** @brief Creates the GPU timestamp query pool; call after device init. */ + void initGpuTiming(); + /** @brief Writes a begin timestamp into the current present command buffer. */ + void writeGpuTimestampBegin(); + /** @brief Writes an end timestamp into the current present command buffer. */ + void writeGpuTimestampEnd(); + /** @brief Reads the query results after the frame's submit has completed. */ + void readGpuFrameTiming(); + + bool gpuTimingAvailable() const override; + float gpuFrameMs() const override; + std::string getBackendName() const override; void initWithWindow(void *nativeWindow) override; @@ -1268,6 +1283,12 @@ private: vkb::DepthStencilImage depthImage; vk::Format depthFormat = vk::Format::eD32Sfloat; + // GPU timestamp queries (one pool of 2: [0]=frame begin, [1]=frame end). + vk::QueryPool gpuQueryPool_{}; + float timestampPeriod_ = 1.f; + bool gpuTimingReady_ = false; + float gpuFrameMs_ = 0.f; + struct SolidBatch { BlendMode blend = BlendMode::Alpha; Batcher batch; diff --git a/src/modules/graphics/vulkan/Graphics2D.cpp b/src/modules/graphics/vulkan/Graphics2D.cpp index 5c21d83d..2e38d214 100644 --- a/src/modules/graphics/vulkan/Graphics2D.cpp +++ b/src/modules/graphics/vulkan/Graphics2D.cpp @@ -1632,7 +1632,9 @@ void Graphics::flushToSwapchain() { swapchainPass = {}; const bool captured = screenReadbackEnabled ? recordSwapchainReadback(presentRecording.commandBuffer()) : false; + writeGpuTimestampEnd(); presentRecording.end().submitAndPresent(); + readGpuFrameTiming(); presentRecording = {}; if (captured) { hasPresentedFrame = true; diff --git a/src/modules/graphics/vulkan/GraphicsPipeline.cpp b/src/modules/graphics/vulkan/GraphicsPipeline.cpp index 9570a855..11985924 100644 --- a/src/modules/graphics/vulkan/GraphicsPipeline.cpp +++ b/src/modules/graphics/vulkan/GraphicsPipeline.cpp @@ -139,6 +139,7 @@ vk::Pipeline createSolidColorPipeline(vkb::Device &device, const vkb::BuiltRende // --- Swapchain and graphics pipelines ----------------------------------------- void Graphics::createSwapchainAndPipeline() { + initGpuTiming(); // Framebuffers / command buffers alias the current swapchain images; tear // them down before replacing the swapchain (destroy() waitIdles first). presentRecording = {}; diff --git a/src/modules/graphics/webgpu/BindGroupLayoutBuilder.cpp b/src/modules/graphics/webgpu/BindGroupLayoutBuilder.cpp new file mode 100644 index 00000000..7b7449d7 --- /dev/null +++ b/src/modules/graphics/webgpu/BindGroupLayoutBuilder.cpp @@ -0,0 +1,18 @@ +#include "graphics/webgpu/BindGroupLayoutBuilder.h" + +#include "common/Exception.h" + +namespace eve::graphics::webgpu { + +wgpu::BindGroupLayout BindGroupLayoutBuilder::build(const wgpu::Device &device, + const char *label) const { + if (!device) throw Exception("BindGroupLayoutBuilder::build: null device"); + + wgpu::BindGroupLayoutDescriptor d{}; + d.label = label; + d.entryCount = entries_.size(); + d.entries = entries_.data(); + return device.CreateBindGroupLayout(&d); +} + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/BindGroupLayoutBuilder.h b/src/modules/graphics/webgpu/BindGroupLayoutBuilder.h new file mode 100644 index 00000000..a38b8145 --- /dev/null +++ b/src/modules/graphics/webgpu/BindGroupLayoutBuilder.h @@ -0,0 +1,66 @@ +#pragma once + +#include "graphics/webgpu/wgpu_types.h" + +#include + +namespace eve::graphics::webgpu { + +/** + * @brief Fluent builder for wgpu::BindGroupLayout. + * + * Builds the C++ wrapper descriptor directly (whose zero-init already matches + * the WebGPU defaults) instead of a hand-written WGPUBindGroupLayoutEntry array + * + reinterpret_cast. Mirrors vk-bootstrap's DescriptorSetBuilder. + */ +class BindGroupLayoutBuilder { +public: + /** @brief Default: empty layout with no entries. */ + BindGroupLayoutBuilder() = default; + + /** @brief Append a buffer binding (uniform / storage / read-only storage). */ + BindGroupLayoutBuilder &buffer(uint32_t binding, wgpu::ShaderStage visibility, + wgpu::BufferBindingType type, bool dynamicOffset, + uint64_t minBindingSize) { + wgpu::BindGroupLayoutEntry e{}; + e.binding = binding; + e.visibility = visibility; + e.buffer = {nullptr, type, dynamicOffset, minBindingSize}; + entries_.push_back(std::move(e)); + return *this; + } + + /** @brief Append a sampled-texture binding. */ + BindGroupLayoutBuilder &texture(uint32_t binding, wgpu::ShaderStage visibility, + wgpu::TextureSampleType sampleType, + wgpu::TextureViewDimension dim, bool multisampled = false) { + wgpu::BindGroupLayoutEntry e{}; + e.binding = binding; + e.visibility = visibility; + e.texture = {nullptr, sampleType, dim, multisampled}; + entries_.push_back(std::move(e)); + return *this; + } + + /** @brief Append a sampler binding (filtering or comparison). */ + BindGroupLayoutBuilder &sampler(uint32_t binding, wgpu::ShaderStage visibility, + wgpu::SamplerBindingType type) { + wgpu::BindGroupLayoutEntry e{}; + e.binding = binding; + e.visibility = visibility; + e.sampler = {nullptr, type}; + entries_.push_back(std::move(e)); + return *this; + } + + /** @brief Number of entries accumulated so far. */ + size_t entryCount() const { return entries_.size(); } + + /** @brief Create the bind group layout. Throws on null device. */ + wgpu::BindGroupLayout build(const wgpu::Device &device, const char *label) const; + +private: + std::vector entries_; +}; + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/Canvas.cpp b/src/modules/graphics/webgpu/Canvas.cpp index 0ad768b0..f2740974 100644 --- a/src/modules/graphics/webgpu/Canvas.cpp +++ b/src/modules/graphics/webgpu/Canvas.cpp @@ -9,11 +9,6 @@ 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) diff --git a/src/modules/graphics/webgpu/Capabilities.cpp b/src/modules/graphics/webgpu/Capabilities.cpp new file mode 100644 index 00000000..58673c40 --- /dev/null +++ b/src/modules/graphics/webgpu/Capabilities.cpp @@ -0,0 +1,69 @@ +#include "graphics/webgpu/Capabilities.h" + +#include + +namespace eve::graphics::webgpu { + +namespace { +constexpr uint32_t kDefaultSampleCount = 4; +} // namespace + +WGPUTextureFormat Capabilities::querySurfaceFormat(wgpu::Adapter &adapter, + wgpu::Surface &surface, + WGPUTextureFormat defaultFormat) { + if (!surface || !adapter) return defaultFormat; + WGPUSurfaceCapabilities caps{}; + if (wgpuSurfaceGetCapabilities(surface.Get(), adapter.Get(), &caps) == WGPUStatus_Success && + caps.formatCount > 0) { + WGPUTextureFormat fmt = caps.formats[0]; + wgpuSurfaceCapabilitiesFreeMembers(caps); + return fmt; + } + wgpuSurfaceCapabilitiesFreeMembers(caps); + return defaultFormat; +} + +void Capabilities::capture(wgpu::Adapter &adapter, wgpu::Device &device) { + // Device limits give the authoritative budget for the feature gates the + // backend actually uses (sample count, bind groups, dynamic uniform + // buffers, texture dimension). Fall back to the adapter limits when the + // device has not been created yet. + wgpu::Limits limits{}; + bool ok = false; + if (device) { + ok = (device.GetLimits(&limits) == wgpu::Status::Success); + } else if (adapter) { + ok = (adapter.GetLimits(&limits) == wgpu::Status::Success); + } + if (ok) { + if (limits.maxTextureDimension2D != wgpu::kLimitU32Undefined) + maxTextureDim2D_ = limits.maxTextureDimension2D; + if (limits.maxBindGroups != wgpu::kLimitU32Undefined) + maxBindGroups_ = limits.maxBindGroups; + if (limits.maxDynamicUniformBuffersPerPipelineLayout != wgpu::kLimitU32Undefined) + maxDynamicUniformBuffers_ = limits.maxDynamicUniformBuffersPerPipelineLayout; + // Max sample count is not a single limit; cap conservatively at 4. + maxSampleCount_ = std::min(maxSampleCount_, 4u); + } + captured_ = true; +} + +bool Capabilities::supportsSampleCount(uint32_t count) const { + if (!captured_) return count <= kDefaultSampleCount; + if (count <= 1) return true; + return count <= maxSampleCount_; +} + +bool Capabilities::supportsFormat(WGPUTextureFormat format) const { + // The formats the backend creates are core WebGPU renderable formats that + // the fixed pipelines require; report them as supported. + switch (format) { + case WGPUTextureFormat_RGBA8Unorm: + case WGPUTextureFormat_Depth32Float: + return true; + default: + return false; + } +} + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/Capabilities.h b/src/modules/graphics/webgpu/Capabilities.h new file mode 100644 index 00000000..9a5e5035 --- /dev/null +++ b/src/modules/graphics/webgpu/Capabilities.h @@ -0,0 +1,65 @@ +#pragma once + +#include "graphics/webgpu/wgpu_types.h" + +#include + +namespace eve::graphics::webgpu { + +/** + * @brief Read-only snapshot of WebGPU adapter/device limits and surface + * capabilities, queried once after device creation. + * + * Mirrors the capability-probe pattern of vk-bootstrap (PhysicalDevice/Device + * queries) so feature-gated paths (MSAA sample count, formats, bind-group + * budget) can reject infeasible requests before issuing a GPU call, instead of + * guessing and crashing at validation time. + */ +class Capabilities { +public: + /** @brief Default-constructed: all queries report their (conservative) default. */ + Capabilities() = default; + + /** + * @brief Query the surface's preferred format from the adapter. + * Falls back to `defaultFormat` when the surface reports none. + */ + static WGPUTextureFormat querySurfaceFormat(wgpu::Adapter &adapter, wgpu::Surface &surface, + WGPUTextureFormat defaultFormat); + + /** + * @brief Populate the snapshot from an adapter and device. + * Call once, right after device creation; keep the returned object. + */ + void capture(wgpu::Adapter &adapter, wgpu::Device &device); + + /** @brief True when the device supports the given render-target sample count. */ + bool supportsSampleCount(uint32_t count) const; + + /** @brief True when the device advertises the given texture format. */ + bool supportsFormat(WGPUTextureFormat format) const; + + /** @brief Maximum 2D texture dimension the device guarantees. */ + uint32_t maxTextureDimension2D() const { return maxTextureDim2D_; } + + /** @brief Maximum bind group count per pipeline layout. */ + uint32_t maxBindGroups() const { return maxBindGroups_; } + + /** @brief Maximum dynamic uniform buffers per pipeline layout. */ + uint32_t maxDynamicUniformBuffers() const { return maxDynamicUniformBuffers_; } + + /** @brief Maximum sampler anisotropy the device supports (>=1). */ + float maxSamplerAnisotropy() const { return maxAnisotropy_; } + +private: + // Conservative defaults: a value that is never worse than a guaranteed limit + // so an uncaptured Capabilities still lets valid calls through. + uint32_t maxTextureDim2D_ = 16384; + uint32_t maxBindGroups_ = 4; + uint32_t maxDynamicUniformBuffers_ = 8; + float maxAnisotropy_ = 16.f; + uint32_t maxSampleCount_ = 4; + bool captured_ = false; +}; + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/Graphics.cpp b/src/modules/graphics/webgpu/Graphics.cpp index 82694670..9bee240d 100644 --- a/src/modules/graphics/webgpu/Graphics.cpp +++ b/src/modules/graphics/webgpu/Graphics.cpp @@ -10,6 +10,9 @@ #include "graphics/Texture.h" #include "graphics/TextureSampler.h" #include "graphics/webgpu/Canvas.h" +#include "graphics/webgpu/InitFlow.h" +#include "graphics/webgpu/BindGroupLayoutBuilder.h" +#include "graphics/webgpu/PipelineBuilder.h" #include "graphics/webgpu/wgsl_shaders.h" #include "common/Exception.h" @@ -51,11 +54,6 @@ 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}; -} - // Forward declaration for the readback helper (defined in the Readback section). bool copyTextureToCpu(wgpu::Instance &instance, wgpu::Device &device, wgpu::Queue &queue, wgpu::Texture src, int width, int height, std::vector &outRgba); @@ -109,9 +107,15 @@ void Graphics::initHeadless(int width, int height) { } if (width <= 0 || height <= 0) throw Exception("Graphics::initHeadless: invalid size"); - createInstanceAndAdapter(); - requestDevice(); - queue = device.GetQueue(); + auto inst = InitFlow::createInstance(); + auto adp = InitFlow::requestAdapter(std::move(inst), surface); + auto dev = InitFlow::requestDevice(std::move(adp)); + + instance = std::move(dev.instance); + adapter = std::move(dev.adapter); + device = std::move(dev.device); + queue = std::move(dev.queue); + caps = std::move(dev.caps); surfaceFormat = WGPUTextureFormat_BGRA8Unorm; createPipelineResources(); @@ -128,11 +132,15 @@ void Graphics::initWithWindow(void *nativeWindow) { sdlWindow = nativeWindow; if (deviceInitDone) return; - createInstanceAndAdapter(); - requestDevice(); - if (!device) throw Exception("WebGPU: device request failed"); + auto inst = InitFlow::createInstance(); + auto adp = InitFlow::requestAdapter(std::move(inst), surface); + auto dev = InitFlow::requestDevice(std::move(adp)); - queue = device.GetQueue(); + instance = std::move(dev.instance); + adapter = std::move(dev.adapter); + device = std::move(dev.device); + queue = std::move(dev.queue); + caps = std::move(dev.caps); // ---- Surface ---- WGPUSurfaceDescriptor surfDesc{}; @@ -226,104 +234,6 @@ void Graphics::initWithWindow(void *nativeWindow) { deviceInitDone = true; } -void Graphics::createInstanceAndAdapter() { -#if defined(__EMSCRIPTEN__) - instance = wgpu::CreateInstance(); -#else - const WGPUInstanceFeatureName requiredFeatures[] = {WGPUInstanceFeatureName_TimedWaitAny}; - WGPUInstanceDescriptor instanceDesc{}; - instanceDesc.requiredFeatureCount = 1; - instanceDesc.requiredFeatures = requiredFeatures; - instance = wgpu::CreateInstance(reinterpret_cast(&instanceDesc)); -#endif - 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"); - devDesc.uncapturedErrorCallbackInfo.callback = - [](WGPUDevice const *, WGPUErrorType type, WGPUStringView message, void *, void *) { - std::fprintf(stderr, "[webgpu] uncaptured error type=%d: %.*s\n", int(type), - int(message.length), message.data ? message.data : ""); - }; - - 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); @@ -484,13 +394,12 @@ void Graphics::createDefaultTextures() { // --------------------------------------------------------------------------- void Graphics::createPipelineResources() { + // Only layouts are required during initialization. Native Dawn compiles + // render pipelines synchronously, so eagerly materializing every blend, + // depth, cull, target and MSAA variant made each isolated CTest spend + // roughly a minute compiling pipelines it never used. create2DPipelines(); createMesh3DPipelines(); - createMesh3DClusteredPipeline(); - createShadowPipelines(); - createGbufferPipelines(); - createDecalPipeline(); - createVoxelPipelines(); } // --------------------------------------------------------------------------- @@ -498,378 +407,223 @@ void Graphics::createPipelineResources() { // --------------------------------------------------------------------------- wgpu::BindGroupLayout Graphics::make2DBindGroupLayout() { - WGPUBindGroupLayoutEntry entries[5]{}; + BindGroupLayoutBuilder b; // 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; + b.texture(0, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 1: depth texture - entries[1].binding = 1; - entries[1].visibility = WGPUShaderStage_Fragment; - entries[1].texture.sampleType = WGPUTextureSampleType_Float; - entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(1, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 2: color sampler - entries[2].binding = 2; - entries[2].visibility = WGPUShaderStage_Fragment; - entries[2].sampler.type = WGPUSamplerBindingType_Filtering; + b.sampler(2, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); // 3: depth sampler - entries[3].binding = 3; - entries[3].visibility = WGPUShaderStage_Fragment; - entries[3].sampler.type = WGPUSamplerBindingType_Filtering; + b.sampler(3, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::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)); + b.buffer(4, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment, + wgpu::BufferBindingType::Uniform, true, + std::max(Shader::kPushConstantBytes, uint32_t(sizeof(Lighting2DUBO)))); + return b.build(device, "eve_2d"); } wgpu::BindGroupLayout Graphics::makeMesh3DBindGroupLayout() { - WGPUBindGroupLayoutEntry entries[16]{}; + BindGroupLayoutBuilder b; // 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); + b.buffer(0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment, + wgpu::BufferBindingType::Uniform, true, 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; + b.texture(1, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 2: normal - entries[2].binding = 2; - entries[2].visibility = WGPUShaderStage_Fragment; - entries[2].texture.sampleType = WGPUTextureSampleType_Float; - entries[2].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(2, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 3: env cubemap - entries[3].binding = 3; - entries[3].visibility = WGPUShaderStage_Fragment; - entries[3].texture.sampleType = WGPUTextureSampleType_Float; - entries[3].texture.viewDimension = WGPUTextureViewDimension_Cube; + b.texture(3, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::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); + b.buffer(4, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::Uniform, true, + 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; + b.texture(5, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Depth, + wgpu::TextureViewDimension::e2DArray); // 6: height - entries[6].binding = 6; - entries[6].visibility = WGPUShaderStage_Fragment; - entries[6].texture.sampleType = WGPUTextureSampleType_Float; - entries[6].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(6, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 7: shared filtering sampler (WGSL `mainSamp`) - entries[7].binding = 7; - entries[7].visibility = WGPUShaderStage_Fragment; - entries[7].sampler.type = WGPUSamplerBindingType_Filtering; + b.sampler(7, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); // 8: shadow comparison sampler - entries[8].binding = 8; - entries[8].visibility = WGPUShaderStage_Fragment; - entries[8].sampler.type = WGPUSamplerBindingType_Comparison; + b.sampler(8, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Comparison); // 9: scene depth (G-buffer hwDepth; X-ray shaders sample it). Depth view, // sampled via textureSampleLevel with the shared mainSamp (binding 7). - entries[9].binding = 9; - entries[9].visibility = WGPUShaderStage_Fragment; - entries[9].texture.sampleType = WGPUTextureSampleType_Depth; - entries[9].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(9, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Depth, + wgpu::TextureViewDimension::e2D); // 10: SSAO occlusion texture (white when AO is disabled) - entries[10].binding = 10; - entries[10].visibility = WGPUShaderStage_Fragment; - entries[10].texture.sampleType = WGPUTextureSampleType_Float; - entries[10].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(10, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 11: shared AO sampler - entries[11].binding = 11; - entries[11].visibility = WGPUShaderStage_Fragment; - entries[11].sampler.type = WGPUSamplerBindingType_Filtering; + b.sampler(11, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); for (uint32_t i = 12; i <= 14; ++i) { - entries[i].binding = i; - entries[i].visibility = WGPUShaderStage_Fragment; - entries[i].texture.sampleType = WGPUTextureSampleType_Float; - entries[i].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(i, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); } - entries[15].binding = 15; - // Custom mesh shaders may use their external parameters for vertex - // deformation as well as fragment shading (for example grass billboards). - entries[15].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; - entries[15].buffer.type = WGPUBufferBindingType_Uniform; - entries[15].buffer.hasDynamicOffset = true; - entries[15].buffer.minBindingSize = Shader::kPushConstantBytes; - - WGPUBindGroupLayoutDescriptor desc{}; - desc.label = sv("eve_mesh3d"); - desc.entryCount = 16; - desc.entries = entries; - return device.CreateBindGroupLayout(reinterpret_cast(&desc)); + // 15: custom mesh shader external parameters (vertex + fragment). + b.buffer(15, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment, + wgpu::BufferBindingType::Uniform, true, Shader::kPushConstantBytes); + return b.build(device, "eve_mesh3d"); } wgpu::BindGroupLayout Graphics::makeShadowBindGroupLayout() { - WGPUBindGroupLayoutEntry entries[3]{}; - entries[0].binding = 0; - entries[0].visibility = WGPUShaderStage_Vertex; - entries[0].buffer.type = WGPUBufferBindingType_Uniform; - entries[0].buffer.hasDynamicOffset = true; - entries[0].buffer.minBindingSize = sizeof(SkinPassUBO); - entries[1].binding = 1; - entries[1].visibility = WGPUShaderStage_Fragment; - entries[1].texture.sampleType = WGPUTextureSampleType_Float; - entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; - entries[2].binding = 2; - entries[2].visibility = WGPUShaderStage_Fragment; - entries[2].sampler.type = WGPUSamplerBindingType_Filtering; - - WGPUBindGroupLayoutDescriptor desc{}; - desc.label = sv("eve_shadow"); - desc.entryCount = 3; - desc.entries = entries; - return device.CreateBindGroupLayout(reinterpret_cast(&desc)); + BindGroupLayoutBuilder b; + b.buffer(0, wgpu::ShaderStage::Vertex, wgpu::BufferBindingType::Uniform, true, + sizeof(SkinPassUBO)); + b.texture(1, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); + b.sampler(2, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); + return b.build(device, "eve_shadow"); } wgpu::BindGroupLayout Graphics::makeGbufferBindGroupLayout() { - WGPUBindGroupLayoutEntry entries[3]{}; + BindGroupLayoutBuilder b; // 0: pass and skinning 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(SkinPassUBO); + b.buffer(0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment, + wgpu::BufferBindingType::Uniform, true, sizeof(SkinPassUBO)); // 1: albedo - entries[1].binding = 1; - entries[1].visibility = WGPUShaderStage_Fragment; - entries[1].texture.sampleType = WGPUTextureSampleType_Float; - entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(1, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 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)); + b.sampler(2, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); + return b.build(device, "eve_gbuffer"); } wgpu::BindGroupLayout Graphics::makeDecalBindGroupLayout() { - WGPUBindGroupLayoutEntry entries[7]{}; - entries[0].binding = 0; - entries[0].visibility = WGPUShaderStage_Fragment; - entries[0].buffer.type = WGPUBufferBindingType_Uniform; - entries[0].buffer.hasDynamicOffset = true; - entries[0].buffer.minBindingSize = 240; + BindGroupLayoutBuilder b; + b.buffer(0, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::Uniform, true, 240); for (uint32_t i = 1; i <= 3; ++i) { - entries[i].binding = i; - entries[i].visibility = WGPUShaderStage_Fragment; - entries[i].texture.sampleType = WGPUTextureSampleType_Float; - entries[i].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(i, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); } - entries[4].binding = 4; - entries[4].visibility = WGPUShaderStage_Fragment; - entries[4].texture.sampleType = WGPUTextureSampleType_Depth; - entries[4].texture.viewDimension = WGPUTextureViewDimension_2D; - entries[5].binding = 5; - entries[5].visibility = WGPUShaderStage_Fragment; - entries[5].texture.sampleType = WGPUTextureSampleType_Float; - entries[5].texture.viewDimension = WGPUTextureViewDimension_2D; - entries[6].binding = 6; - entries[6].visibility = WGPUShaderStage_Fragment; - entries[6].sampler.type = WGPUSamplerBindingType_Filtering; - WGPUBindGroupLayoutDescriptor desc{}; - desc.label = sv("eve_decal"); - desc.entryCount = 7; - desc.entries = entries; - return device.CreateBindGroupLayout( - reinterpret_cast(&desc)); + b.texture(4, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Depth, + wgpu::TextureViewDimension::e2D); + b.texture(5, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); + b.sampler(6, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); + return b.build(device, "eve_decal"); } wgpu::BindGroupLayout Graphics::makeVoxelBindGroupLayout() { - WGPUBindGroupLayoutEntry entries[3]{}; + BindGroupLayoutBuilder b; // 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; + b.buffer(0, wgpu::ShaderStage::Vertex, wgpu::BufferBindingType::Uniform, true, 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; + b.texture(1, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 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)); + b.sampler(2, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); + return b.build(device, "eve_voxel"); } wgpu::PipelineLayout Graphics::make2DPipelineLayout() { - WGPUBindGroupLayout bgl = tex2DSetLayout.Get(); - WGPUPipelineLayoutDescriptor d{}; - d.label = sv("eve_2d_layout"); + wgpu::PipelineLayoutDescriptor d{}; + d.label = "eve_2d_layout"; d.bindGroupLayoutCount = 1; - d.bindGroupLayouts = &bgl; - return device.CreatePipelineLayout(reinterpret_cast(&d)); + d.bindGroupLayouts = &tex2DSetLayout; + return device.CreatePipelineLayout(&d); } wgpu::PipelineLayout Graphics::makeMesh3DPipelineLayout() { - WGPUBindGroupLayout bgl = mesh3dSetLayout.Get(); - WGPUPipelineLayoutDescriptor d{}; - d.label = sv("eve_mesh3d_layout"); + wgpu::PipelineLayoutDescriptor d{}; + d.label = "eve_mesh3d_layout"; d.bindGroupLayoutCount = 1; - d.bindGroupLayouts = &bgl; - return device.CreatePipelineLayout(reinterpret_cast(&d)); + d.bindGroupLayouts = &mesh3dSetLayout; + return device.CreatePipelineLayout(&d); } wgpu::BindGroupLayout Graphics::makeMesh3DClusteredBindGroupLayout() { - WGPUBindGroupLayoutEntry entries[18]{}; + BindGroupLayoutBuilder b; // 0: Frame UBO (dynamic; clustered layout) - 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(Mesh3DClusteredUBO); + b.buffer(0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment, + wgpu::BufferBindingType::Uniform, true, sizeof(Mesh3DClusteredUBO)); // 1: albedo - entries[1].binding = 1; - entries[1].visibility = WGPUShaderStage_Fragment; - entries[1].texture.sampleType = WGPUTextureSampleType_Float; - entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(1, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 2: normal map - entries[2].binding = 2; - entries[2].visibility = WGPUShaderStage_Fragment; - entries[2].texture.sampleType = WGPUTextureSampleType_Float; - entries[2].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(2, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 3: env cubemap - entries[3].binding = 3; - entries[3].visibility = WGPUShaderStage_Fragment; - entries[3].texture.sampleType = WGPUTextureSampleType_Float; - entries[3].texture.viewDimension = WGPUTextureViewDimension_Cube; + b.texture(3, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::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); + b.buffer(4, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::Uniform, true, + 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; + b.texture(5, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Depth, + wgpu::TextureViewDimension::e2DArray); // 6: height/parallax (unused fallback) - entries[6].binding = 6; - entries[6].visibility = WGPUShaderStage_Fragment; - entries[6].texture.sampleType = WGPUTextureSampleType_Float; - entries[6].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(6, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); // 7: shared filtering sampler - entries[7].binding = 7; - entries[7].visibility = WGPUShaderStage_Fragment; - entries[7].sampler.type = WGPUSamplerBindingType_Filtering; + b.sampler(7, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); // 8: shadow comparison sampler - entries[8].binding = 8; - entries[8].visibility = WGPUShaderStage_Fragment; - entries[8].sampler.type = WGPUSamplerBindingType_Comparison; + b.sampler(8, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Comparison); // 9: scene depth (G-buffer hwDepth; sampled by X-ray variants) - entries[9].binding = 9; - entries[9].visibility = WGPUShaderStage_Fragment; - entries[9].texture.sampleType = WGPUTextureSampleType_Depth; - entries[9].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(9, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Depth, + wgpu::TextureViewDimension::e2D); // 10..12: clustered-forward SSBOs - entries[10].binding = 10; - entries[10].visibility = WGPUShaderStage_Fragment; - entries[10].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; - entries[10].buffer.minBindingSize = sizeof(ClusteredLightGpu); - entries[11].binding = 11; - entries[11].visibility = WGPUShaderStage_Fragment; - entries[11].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; - entries[11].buffer.minBindingSize = sizeof(ClusterTableEntry); - entries[12].binding = 12; - entries[12].visibility = WGPUShaderStage_Fragment; - entries[12].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; - entries[12].buffer.minBindingSize = sizeof(uint32_t); + b.buffer(10, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::ReadOnlyStorage, false, + sizeof(ClusteredLightGpu)); + b.buffer(11, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::ReadOnlyStorage, false, + sizeof(ClusterTableEntry)); + b.buffer(12, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::ReadOnlyStorage, false, + sizeof(uint32_t)); // 13/14: SSAO occlusion texture + sampler - entries[13].binding = 13; - entries[13].visibility = WGPUShaderStage_Fragment; - entries[13].texture.sampleType = WGPUTextureSampleType_Float; - entries[13].texture.viewDimension = WGPUTextureViewDimension_2D; - entries[14].binding = 14; - entries[14].visibility = WGPUShaderStage_Fragment; - entries[14].sampler.type = WGPUSamplerBindingType_Filtering; + b.texture(13, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); + b.sampler(14, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); for (uint32_t i = 15; i <= 17; ++i) { - entries[i].binding = i; - entries[i].visibility = WGPUShaderStage_Fragment; - entries[i].texture.sampleType = WGPUTextureSampleType_Float; - entries[i].texture.viewDimension = WGPUTextureViewDimension_2D; + b.texture(i, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Float, + wgpu::TextureViewDimension::e2D); } - - WGPUBindGroupLayoutDescriptor desc{}; - desc.label = sv("eve_mesh3d_clustered"); - desc.entryCount = 18; - desc.entries = entries; - return device.CreateBindGroupLayout(reinterpret_cast(&desc)); + return b.build(device, "eve_mesh3d_clustered"); } wgpu::PipelineLayout Graphics::makeMesh3DClusteredPipelineLayout() { - WGPUBindGroupLayout bgl = mesh3dClusteredSetLayout.Get(); - WGPUPipelineLayoutDescriptor d{}; - d.label = sv("eve_mesh3d_clustered_layout"); + wgpu::PipelineLayoutDescriptor d{}; + d.label = "eve_mesh3d_clustered_layout"; d.bindGroupLayoutCount = 1; - d.bindGroupLayouts = &bgl; - return device.CreatePipelineLayout(reinterpret_cast(&d)); + d.bindGroupLayouts = &mesh3dClusteredSetLayout; + return device.CreatePipelineLayout(&d); } wgpu::PipelineLayout Graphics::makeShadowPipelineLayout() { - WGPUBindGroupLayout bgl = shadowSetLayout.Get(); - WGPUPipelineLayoutDescriptor d{}; - d.label = sv("eve_shadow_layout"); + wgpu::PipelineLayoutDescriptor d{}; + d.label = "eve_shadow_layout"; d.bindGroupLayoutCount = 1; - d.bindGroupLayouts = &bgl; - return device.CreatePipelineLayout(reinterpret_cast(&d)); + d.bindGroupLayouts = &shadowSetLayout; + return device.CreatePipelineLayout(&d); } wgpu::PipelineLayout Graphics::makeGbufferPipelineLayout() { - WGPUBindGroupLayout bgl = gbufferSetLayout.Get(); - WGPUPipelineLayoutDescriptor d{}; - d.label = sv("eve_gbuffer_layout"); + wgpu::PipelineLayoutDescriptor d{}; + d.label = "eve_gbuffer_layout"; d.bindGroupLayoutCount = 1; - d.bindGroupLayouts = &bgl; - return device.CreatePipelineLayout(reinterpret_cast(&d)); + d.bindGroupLayouts = &gbufferSetLayout; + return device.CreatePipelineLayout(&d); } wgpu::PipelineLayout Graphics::makeDecalPipelineLayout() { - WGPUBindGroupLayout bgl = decalSetLayout.Get(); - WGPUPipelineLayoutDescriptor d{}; - d.label = sv("eve_decal_layout"); + wgpu::PipelineLayoutDescriptor d{}; + d.label = "eve_decal_layout"; d.bindGroupLayoutCount = 1; - d.bindGroupLayouts = &bgl; - return device.CreatePipelineLayout( - reinterpret_cast(&d)); + d.bindGroupLayouts = &decalSetLayout; + return device.CreatePipelineLayout(&d); } wgpu::PipelineLayout Graphics::makeVoxelPipelineLayout() { - WGPUBindGroupLayout bgl = voxelSetLayout.Get(); - WGPUPipelineLayoutDescriptor d{}; - d.label = sv("eve_voxel_layout"); + wgpu::PipelineLayoutDescriptor d{}; + d.label = "eve_voxel_layout"; d.bindGroupLayoutCount = 1; - d.bindGroupLayouts = &bgl; - return device.CreatePipelineLayout(reinterpret_cast(&d)); + d.bindGroupLayouts = &voxelSetLayout; + return device.CreatePipelineLayout(&d); } // --------------------------------------------------------------------------- @@ -883,12 +637,11 @@ namespace { // 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)); + wgpu::ShaderSourceWGSL wgslDesc{}; + wgslDesc.code = wgsl; + wgpu::ShaderModuleDescriptor desc{}; + desc.nextInChain = &wgslDesc; + return dev.CreateShaderModule(&desc); } // Builds a WGSL shader-module descriptor (the chained WGPUShaderSourceWGSL is @@ -982,178 +735,73 @@ namespace { wgpu::RenderPipeline make2DColorPipeline(wgpu::Device &dev, WGPUTextureFormat format, BlendMode mode) { - WGPUVertexAttribute attrs[2] = {}; - attrs[0].format = WGPUVertexFormat_Float32x2; + wgpu::VertexAttribute attrs[2] = {}; + attrs[0].format = wgpu::VertexFormat::Float32x2; attrs[0].offset = 0; attrs[0].shaderLocation = 0; - attrs[1].format = WGPUVertexFormat_Float32x4; + attrs[1].format = wgpu::VertexFormat::Float32x4; attrs[1].offset = 8; attrs[1].shaderLocation = 1; - WGPUVertexBufferLayout vb{}; - fillVertexLayout(vb, 24, attrs, 2); - - WGPUColorTargetState target{}; - target.format = format; - target.blend = nullptr; - // Zero-init yields WGPUColorWriteMask_None, which silently discards every - // fragment (the clear still shows because clearValue is unaffected by the - // mask). emdawnwebgpu forwards this explicit 0 to the browser, unlike the - // JS default of "all". - target.writeMask = WGPUColorWriteMask_All; - WGPUBlendState bs = alphaBlend(); - if (mode == BlendMode::Additive) - bs = additiveBlend(); - else if (mode == BlendMode::Premultiplied) - bs = premultipliedBlend(); - else if (mode == BlendMode::Multiply) - bs = multiplyBlend(); - else if (mode == BlendMode::Opaque) - bs = noBlend(); - if (mode != BlendMode::Opaque) target.blend = &bs; + PipelineBuilder b; // 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"); - // nullptr = auto (default) pipeline layout; the solid-color shader has no - // bindings so the derived layout matches. - pd.layout = nullptr; - 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; + // (auto-derived) pipeline layout. Reusing the textured 2D layout here would + // require a bind group for every solid draw. + b.vertexLayout(24, wgpu::VertexStepMode::Vertex, attrs, 2); + b.shader(makeWgslModule(dev, kColorVertWgsl), "vs_main", makeWgslModule(dev, kColorFragWgsl), + "fs_main"); + b.colorTarget(format, mode); // The WGSL vertex shader mirrors clip Y to match Vulkan, which flips // object-space CCW winding in framebuffer space. - pd.primitive.frontFace = WGPUFrontFace_CW; - pd.primitive.cullMode = WGPUCullMode_Back; - pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; - pd.depthStencil = nullptr; - pd.multisample.count = 1; - // Zero-init would leave mask=0, which discards every fragment - // (sampleMask=0). The WebGPU default is 0xFFFFFFFF (all samples). - pd.multisample.mask = 0xFFFFFFFFu; - return dev.CreateRenderPipeline(reinterpret_cast(&pd)); + b.frontFace(wgpu::FrontFace::CW); + b.cull(wgpu::CullMode::Back); + return b.build(dev); } wgpu::RenderPipeline make2DTexturedPipeline(wgpu::Device &dev, wgpu::PipelineLayout layout, WGPUTextureFormat format, BlendMode mode) { - WGPUVertexAttribute attrs[3] = {}; - attrs[0].format = WGPUVertexFormat_Float32x2; + wgpu::VertexAttribute attrs[3] = {}; + attrs[0].format = wgpu::VertexFormat::Float32x2; attrs[0].offset = 0; attrs[0].shaderLocation = 0; - attrs[1].format = WGPUVertexFormat_Float32x4; + attrs[1].format = wgpu::VertexFormat::Float32x4; attrs[1].offset = 8; attrs[1].shaderLocation = 1; - attrs[2].format = WGPUVertexFormat_Float32x2; + attrs[2].format = wgpu::VertexFormat::Float32x2; attrs[2].offset = 24; attrs[2].shaderLocation = 2; - WGPUVertexBufferLayout vb{}; - fillVertexLayout(vb, 32, attrs, 3); - WGPUColorTargetState target{}; - target.format = format; - target.writeMask = WGPUColorWriteMask_All; - WGPUBlendState bs = alphaBlend(); - if (mode == BlendMode::Additive) - bs = additiveBlend(); - else if (mode == BlendMode::Premultiplied) - bs = premultipliedBlend(); - else if (mode == BlendMode::Multiply) - bs = multiplyBlend(); - else if (mode == BlendMode::Opaque) - bs = noBlend(); - if (mode != BlendMode::Opaque) 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; - // Zero-init would leave mask=0, which discards every fragment - // (sampleMask=0). The WebGPU default is 0xFFFFFFFF (all samples). - pd.multisample.mask = 0xFFFFFFFFu; - return dev.CreateRenderPipeline(reinterpret_cast(&pd)); + PipelineBuilder b; + b.vertexLayout(32, wgpu::VertexStepMode::Vertex, attrs, 3); + b.shader(makeWgslModule(dev, kTexturedVertWgsl), "vs_main", + makeWgslModule(dev, kTexturedFragWgsl), "fs_main"); + b.layout(layout); + b.colorTarget(format, mode); + return b.build(dev); } wgpu::RenderPipeline make2DLitPipeline(wgpu::Device &dev, wgpu::PipelineLayout layout, WGPUTextureFormat format, bool blend) { - WGPUVertexAttribute attrs[3] = {}; - attrs[0].format = WGPUVertexFormat_Float32x2; + wgpu::VertexAttribute attrs[3] = {}; + attrs[0].format = wgpu::VertexFormat::Float32x2; attrs[0].offset = 0; attrs[0].shaderLocation = 0; - attrs[1].format = WGPUVertexFormat_Float32x4; + attrs[1].format = wgpu::VertexFormat::Float32x4; attrs[1].offset = 8; attrs[1].shaderLocation = 1; - attrs[2].format = WGPUVertexFormat_Float32x2; + attrs[2].format = wgpu::VertexFormat::Float32x2; attrs[2].offset = 24; attrs[2].shaderLocation = 2; - WGPUVertexBufferLayout vb{}; - fillVertexLayout(vb, 32, attrs, 3); - WGPUColorTargetState target{}; - target.format = format; - target.writeMask = WGPUColorWriteMask_All; - WGPUBlendState bs = alphaBlend(); - if (blend) target.blend = &bs; - - WGPURenderPipelineDescriptor pd{}; - pd.label = sv("eve_lit2d"); - pd.layout = layout.Get(); - wgpu::ShaderModule vertModule = makeWgslModule(dev, kLit2DVertWgsl); - 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; - // Zero-init would leave mask=0, which discards every fragment - // (sampleMask=0). The WebGPU default is 0xFFFFFFFF (all samples). - pd.multisample.mask = 0xFFFFFFFFu; - return dev.CreateRenderPipeline(reinterpret_cast(&pd)); + PipelineBuilder b; + b.vertexLayout(32, wgpu::VertexStepMode::Vertex, attrs, 3); + b.shader(makeWgslModule(dev, kLit2DVertWgsl), "vs_main", makeWgslModule(dev, kLit2DFragWgsl), + "fs_main"); + b.layout(layout); + // Lit pipeline always uses alpha blending; `blend` currently true in all + // call sites, so map to Alpha (or Opaque when disabled). + b.colorTarget(format, blend ? BlendMode::Alpha : BlendMode::Opaque); + return b.build(dev); } } // namespace @@ -1161,51 +809,77 @@ wgpu::RenderPipeline make2DLitPipeline(wgpu::Device &dev, wgpu::PipelineLayout l void Graphics::create2DPipelines() { tex2DSetLayout = make2DBindGroupLayout(); tex2DPipelineLayout = make2DPipelineLayout(); +} - colorPipeline = make2DColorPipeline(device, surfaceFormat, BlendMode::Alpha); - texturedPipeline = make2DTexturedPipeline(device, tex2DPipelineLayout, surfaceFormat, - BlendMode::Alpha); - colorAdditivePipeline = make2DColorPipeline(device, surfaceFormat, BlendMode::Additive); - texturedAdditivePipeline = make2DTexturedPipeline(device, tex2DPipelineLayout, surfaceFormat, - BlendMode::Additive); - colorPremultipliedPipeline = - make2DColorPipeline(device, surfaceFormat, BlendMode::Premultiplied); - texturedPremultipliedPipeline = make2DTexturedPipeline( - device, tex2DPipelineLayout, surfaceFormat, BlendMode::Premultiplied); - colorMultiplyPipeline = make2DColorPipeline(device, surfaceFormat, BlendMode::Multiply); - texturedMultiplyPipeline = make2DTexturedPipeline( - device, tex2DPipelineLayout, surfaceFormat, BlendMode::Multiply); - colorOpaquePipeline = make2DColorPipeline(device, surfaceFormat, BlendMode::Opaque); - texturedOpaquePipeline = make2DTexturedPipeline(device, tex2DPipelineLayout, surfaceFormat, - BlendMode::Opaque); - lit2dPipeline = make2DLitPipeline(device, tex2DPipelineLayout, surfaceFormat, true); +wgpu::RenderPipeline Graphics::get2DColorPipeline(BlendMode blend, bool offscreen) { + wgpu::RenderPipeline *pipeline = nullptr; + switch (blend) { + case BlendMode::Additive: + pipeline = offscreen ? &offscreenColorAdditivePipeline : &colorAdditivePipeline; + break; + case BlendMode::Premultiplied: + pipeline = offscreen ? &offscreenColorPremultipliedPipeline + : &colorPremultipliedPipeline; + break; + case BlendMode::Multiply: + pipeline = offscreen ? &offscreenColorMultiplyPipeline : &colorMultiplyPipeline; + break; + case BlendMode::Opaque: + pipeline = offscreen ? &offscreenColorOpaquePipeline : &colorOpaquePipeline; + break; + case BlendMode::Alpha: + default: + pipeline = offscreen ? &offscreenColorPipeline : &colorPipeline; + break; + } + if (!*pipeline) { + const WGPUTextureFormat format = + offscreen ? WGPUTextureFormat_RGBA8Unorm : surfaceFormat; + *pipeline = make2DColorPipeline(device, format, blend); + } + return *pipeline; +} - offscreenColorPipeline = make2DColorPipeline(device, WGPUTextureFormat_RGBA8Unorm, - BlendMode::Alpha); - offscreenTexturedPipeline = make2DTexturedPipeline(device, tex2DPipelineLayout, - WGPUTextureFormat_RGBA8Unorm, - BlendMode::Alpha); - offscreenColorAdditivePipeline = make2DColorPipeline(device, WGPUTextureFormat_RGBA8Unorm, - BlendMode::Additive); - offscreenTexturedAdditivePipeline = - make2DTexturedPipeline(device, tex2DPipelineLayout, WGPUTextureFormat_RGBA8Unorm, - BlendMode::Additive); - offscreenColorPremultipliedPipeline = make2DColorPipeline( - device, WGPUTextureFormat_RGBA8Unorm, BlendMode::Premultiplied); - offscreenTexturedPremultipliedPipeline = make2DTexturedPipeline( - device, tex2DPipelineLayout, WGPUTextureFormat_RGBA8Unorm, - BlendMode::Premultiplied); - offscreenColorMultiplyPipeline = make2DColorPipeline( - device, WGPUTextureFormat_RGBA8Unorm, BlendMode::Multiply); - offscreenTexturedMultiplyPipeline = make2DTexturedPipeline( - device, tex2DPipelineLayout, WGPUTextureFormat_RGBA8Unorm, BlendMode::Multiply); - offscreenColorOpaquePipeline = make2DColorPipeline(device, WGPUTextureFormat_RGBA8Unorm, - BlendMode::Opaque); - offscreenTexturedOpaquePipeline = - make2DTexturedPipeline(device, tex2DPipelineLayout, WGPUTextureFormat_RGBA8Unorm, - BlendMode::Opaque); - offscreenLitPipeline = make2DLitPipeline(device, tex2DPipelineLayout, - WGPUTextureFormat_RGBA8Unorm, true); +wgpu::RenderPipeline Graphics::get2DTexturedPipeline(BlendMode blend, bool offscreen) { + wgpu::RenderPipeline *pipeline = nullptr; + switch (blend) { + case BlendMode::Additive: + pipeline = offscreen ? &offscreenTexturedAdditivePipeline + : &texturedAdditivePipeline; + break; + case BlendMode::Premultiplied: + pipeline = offscreen ? &offscreenTexturedPremultipliedPipeline + : &texturedPremultipliedPipeline; + break; + case BlendMode::Multiply: + pipeline = offscreen ? &offscreenTexturedMultiplyPipeline + : &texturedMultiplyPipeline; + break; + case BlendMode::Opaque: + pipeline = offscreen ? &offscreenTexturedOpaquePipeline + : &texturedOpaquePipeline; + break; + case BlendMode::Alpha: + default: + pipeline = offscreen ? &offscreenTexturedPipeline : &texturedPipeline; + break; + } + if (!*pipeline) { + const WGPUTextureFormat format = + offscreen ? WGPUTextureFormat_RGBA8Unorm : surfaceFormat; + *pipeline = make2DTexturedPipeline(device, tex2DPipelineLayout, format, blend); + } + return *pipeline; +} + +wgpu::RenderPipeline Graphics::get2DLitPipeline(bool offscreen) { + wgpu::RenderPipeline &pipeline = offscreen ? offscreenLitPipeline : lit2dPipeline; + if (!pipeline) { + const WGPUTextureFormat format = + offscreen ? WGPUTextureFormat_RGBA8Unorm : surfaceFormat; + pipeline = make2DLitPipeline(device, tex2DPipelineLayout, format, true); + } + return pipeline; } void Graphics::createMesh3DPipelines() { @@ -1213,6 +887,14 @@ void Graphics::createMesh3DPipelines() { // Bind groups reference the layout; drop cached groups when it changes. clearMeshBindGroupCache(); mesh3dPipelineLayout = makeMesh3DPipelineLayout(); +} + +wgpu::RenderPipeline Graphics::getMesh3DPipeline(BlendMode blend, bool depthWrite, + bool doubleSided, bool canvasTarget) { + const size_t index = meshPipelineIndex(blend, depthWrite, doubleSided); + wgpu::RenderPipeline &pipeline = + canvasTarget ? mesh3dCanvasPipelines[index] : mesh3dPipelines[index]; + if (pipeline) return pipeline; WGPUVertexAttribute attrs[5] = {}; fillMeshAttributes(attrs); @@ -1221,69 +903,48 @@ void Graphics::createMesh3DPipelines() { WGPUDepthStencilState ds{}; ds.format = WGPUTextureFormat_Depth32Float; - ds.depthWriteEnabled = WGPUOptionalBool_True; + ds.depthWriteEnabled = depthWrite ? WGPUOptionalBool_True : WGPUOptionalBool_False; ds.depthCompare = WGPUCompareFunction_Less; - ds.stencilReadMask = 0; - ds.stencilWriteMask = 0; + WGPUBlendState blendDesc = blendState(blend); WGPUColorTargetState target{}; target.format = sceneColorFormat; - target.blend = nullptr; + target.blend = blend == BlendMode::Opaque ? nullptr : &blendDesc; target.writeMask = WGPUColorWriteMask_All; - 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 = ⌖ + + WGPURenderPipelineDescriptor pd{}; + pd.label = canvasTarget ? sv("eve_mesh3d_canvas_surface") : sv("eve_mesh3d_surface"); + pd.layout = mesh3dPipelineLayout.Get(); + pd.vertex.module = vertModule.Get(); + pd.vertex.entryPoint = sv("vs_main"); + pd.vertex.bufferCount = 1; + pd.vertex.buffers = &vb; pd.fragment = &fs; pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; pd.primitive.frontFace = WGPUFrontFace_CW; - pd.primitive.cullMode = WGPUCullMode_None; + pd.primitive.cullMode = doubleSided ? WGPUCullMode_None : WGPUCullMode_Back; pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; pd.depthStencil = &ds; - pd.multisample.count = sceneColorSamples; - // Zero-init would leave mask=0, which discards every fragment - // (sampleMask=0). The WebGPU default is 0xFFFFFFFF (all samples). + pd.multisample.count = canvasTarget ? 1 : sceneColorSamples; pd.multisample.mask = 0xFFFFFFFFu; - for (int blendValue = int(BlendMode::Alpha); blendValue <= int(BlendMode::Multiply); - ++blendValue) { - const BlendMode blend = BlendMode(blendValue); - for (int depthValue = 0; depthValue < 2; ++depthValue) { - for (int doubleValue = 0; doubleValue < 2; ++doubleValue) { - const bool depthWrite = depthValue != 0; - const bool doubleSided = doubleValue != 0; - const size_t index = meshPipelineIndex(blend, depthWrite, doubleSided); - WGPUBlendState blendDesc = blendState(blend); - target.blend = blend == BlendMode::Opaque ? nullptr : &blendDesc; - ds.depthWriteEnabled = - depthWrite ? WGPUOptionalBool_True : WGPUOptionalBool_False; - pd.primitive.cullMode = doubleSided ? WGPUCullMode_None : WGPUCullMode_Back; - pd.multisample.count = sceneColorSamples; - pd.label = sv("eve_mesh3d_surface"); - mesh3dPipelines[index] = device.CreateRenderPipeline( - reinterpret_cast(&pd)); - pd.multisample.count = 1; - pd.label = sv("eve_mesh3d_canvas_surface"); - mesh3dCanvasPipelines[index] = device.CreateRenderPipeline( - reinterpret_cast(&pd)); - } - } - } - mesh3dPipeline = mesh3dPipelines[meshPipelineIndex(BlendMode::Opaque, true, false)]; - mesh3dTransparentPipeline = - mesh3dPipelines[meshPipelineIndex(BlendMode::Alpha, false, false)]; - mesh3dCanvasPipeline = - mesh3dCanvasPipelines[meshPipelineIndex(BlendMode::Opaque, true, false)]; + pipeline = device.CreateRenderPipeline( + reinterpret_cast(&pd)); + + if (!canvasTarget && blend == BlendMode::Opaque && depthWrite && !doubleSided) + mesh3dPipeline = pipeline; + if (!canvasTarget && blend == BlendMode::Alpha && !depthWrite && !doubleSided) + mesh3dTransparentPipeline = pipeline; + if (canvasTarget && blend == BlendMode::Opaque && depthWrite && !doubleSided) + mesh3dCanvasPipeline = pipeline; + return pipeline; } void Graphics::createMesh3DClusteredPipeline() { @@ -1601,24 +1262,12 @@ void Graphics::createVoxelPipelines() { void Graphics::ensureAOResources(int width, int height) { if (!device || width <= 0 || height <= 0) return; if (!aoSetLayout) { - WGPUBindGroupLayoutEntry entries[3]{}; - entries[0].binding = 0; - entries[0].visibility = WGPUShaderStage_Fragment; - entries[0].buffer.type = WGPUBufferBindingType_Uniform; - entries[0].buffer.minBindingSize = 32; - entries[1].binding = 1; - entries[1].visibility = WGPUShaderStage_Fragment; - entries[1].texture.sampleType = WGPUTextureSampleType_Depth; - entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; - entries[2].binding = 2; - entries[2].visibility = WGPUShaderStage_Fragment; - entries[2].sampler.type = WGPUSamplerBindingType_Filtering; - WGPUBindGroupLayoutDescriptor ld{}; - ld.label = sv("eve_ao"); - ld.entryCount = 3; - ld.entries = entries; - aoSetLayout = - device.CreateBindGroupLayout(reinterpret_cast(&ld)); + BindGroupLayoutBuilder b; + b.buffer(0, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::Uniform, false, 32); + b.texture(1, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Depth, + wgpu::TextureViewDimension::e2D); + b.sampler(2, wgpu::ShaderStage::Fragment, wgpu::SamplerBindingType::Filtering); + aoSetLayout = b.build(device, "eve_ao"); WGPUBindGroupLayout layouts[1] = {aoSetLayout.Get()}; WGPUPipelineLayoutDescriptor pl{}; pl.label = sv("eve_ao_layout"); @@ -1694,7 +1343,7 @@ void Graphics::ensureAOResources(int width, int height) { aoView[i] = aoTex[i].CreateView(); } aoReady = true; - // New AO binding — rebuild cached mesh bind groups. + // New AO binding �?rebuild cached mesh bind groups. clearMeshBindGroupCache(); } if (!fullscreenQuadReady) { @@ -2973,20 +2622,7 @@ void Graphics::flush2D(wgpu::RenderPassEncoder pass, int viewW, int viewH, } auto solidPipe = [&](BlendMode mode) -> wgpu::RenderPipeline { - switch (mode) { - case BlendMode::Additive: - return offscreen ? offscreenColorAdditivePipeline : colorAdditivePipeline; - case BlendMode::Premultiplied: - return offscreen ? offscreenColorPremultipliedPipeline - : colorPremultipliedPipeline; - case BlendMode::Multiply: - return offscreen ? offscreenColorMultiplyPipeline : colorMultiplyPipeline; - case BlendMode::Opaque: - return offscreen ? offscreenColorOpaquePipeline : colorOpaquePipeline; - case BlendMode::Alpha: - default: - return offscreen ? offscreenColorPipeline : colorPipeline; - } + return get2DColorPipeline(mode, offscreen); }; auto drawSolid = [&](uint32_t batchIndex, uint32_t first, uint32_t count) { @@ -3071,26 +2707,7 @@ void Graphics::drawTexturedBatch(wgpu::RenderPassEncoder pass, TexturedBatch &tb auto *gs = static_cast(tb.shader->gpuHandle); pipe = offscreen ? gs->offscreenPipeline : gs->swapchainPipeline; } else { - switch (tb.blend) { - case BlendMode::Additive: - pipe = offscreen ? offscreenTexturedAdditivePipeline : texturedAdditivePipeline; - break; - case BlendMode::Premultiplied: - pipe = offscreen ? offscreenTexturedPremultipliedPipeline - : texturedPremultipliedPipeline; - break; - case BlendMode::Multiply: - pipe = offscreen ? offscreenTexturedMultiplyPipeline - : texturedMultiplyPipeline; - break; - case BlendMode::Opaque: - pipe = offscreen ? offscreenTexturedOpaquePipeline : texturedOpaquePipeline; - break; - case BlendMode::Alpha: - default: - pipe = offscreen ? offscreenTexturedPipeline : texturedPipeline; - break; - } + pipe = get2DTexturedPipeline(tb.blend, offscreen); } if (!pipe) return; pass.SetPipeline(pipe); @@ -3136,7 +2753,7 @@ void Graphics::drawLitBatch(wgpu::RenderPassEncoder pass, LitBatch &lb, int view wgpu::BindGroup bg = makeTex2DBindGroup(albedoGpu, normalGpu); uint32_t offsets[1] = {uboOffset}; wgpu::RenderPipeline pipe = - uint32_t(format) == uint32_t(surfaceFormat) ? lit2dPipeline : offscreenLitPipeline; + get2DLitPipeline(uint32_t(format) != uint32_t(surfaceFormat)); if (!pipe) return; pass.SetPipeline(pipe); pass.SetBindGroup(0, bg, 1, offsets); @@ -3627,11 +3244,14 @@ void Graphics::createSceneColorResources(int width, int height) { } sceneColorTexture = &sceneColorSlots[0].colorTex; // 3D pipelines that render into the scene target must match its sample - // count; rebuild them when the count changes (first configure defaults to - // 1, then the engine's msaaSamples (default 4) takes effect). - if (samplesChanged && mesh3dPipeline) { - createMesh3DPipelines(); - createVoxelPipelines(); + // count. Invalidate only the affected cached variants; they are rebuilt + // individually if a later draw actually needs them. + if (samplesChanged) { + mesh3dPipelines = {}; + mesh3dPipeline = {}; + mesh3dTransparentPipeline = {}; + mesh3dClusteredPipeline = {}; + voxelRectPipeline = {}; } if (samplesChanged && gpuDrivenCullPipeline_) { // The forward indirect pipeline inherits the scene target sample @@ -3835,6 +3455,17 @@ void Graphics::flushMesh3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat forma bool canvasTarget) { if (mesh3dDraws.empty()) return; + if (mesh3dClusteredActive && !canvasTarget && !mesh3dClusteredPipeline) { + for (const auto &d : mesh3dDraws) { + const bool customShader = d.shader && d.shader->gpuHandle; + if (!customShader && !d.doubleSided && d.surfaceMode != SurfaceMode::Transparent && + d.mesh && !d.mesh->hasGpuSkinning()) { + createMesh3DClusteredPipeline(); + break; + } + } + } + auto &uboArena = currentUboArena(); ensureUboArena(uboArena, uboArena.used + mesh3dDraws.size() * 10240); auto &vtxArena = currentVertexArena(); @@ -3945,12 +3576,10 @@ void Graphics::flushMesh3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat forma const bool transparent = d.surfaceMode == SurfaceMode::Transparent; const BlendMode blend = transparent ? d.surfaceBlend : BlendMode::Opaque; const bool depthWrite = !transparent || d.depthWrite; - const size_t pipelineIndex = meshPipelineIndex(blend, depthWrite, d.doubleSided); // Canvas targets are 1-sample; scene pipelines follow the active MSAA // count. Both sets preserve the per-draw material raster state. - wgpu::RenderPipeline pipe = canvasTarget ? mesh3dCanvasPipelines[pipelineIndex] - : mesh3dPipelines[pipelineIndex]; const bool customShader = d.shader && d.shader->gpuHandle; + wgpu::RenderPipeline pipe; if (d.shader && d.shader->gpuHandle) { auto *gs = static_cast(d.shader->gpuHandle); if (gs->isMesh3D && gs->mesh3dPipeline) { @@ -3960,6 +3589,7 @@ void Graphics::flushMesh3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat forma pipe = gs->mesh3dPipeline; } } + if (!pipe) pipe = getMesh3DPipeline(blend, depthWrite, d.doubleSided, canvasTarget); const bool useClustered = !canvasTarget && !customShader && !d.doubleSided && mesh3dClusteredActive && d.surfaceMode != SurfaceMode::Transparent && @@ -4013,6 +3643,7 @@ void Graphics::flushMesh3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat forma void Graphics::flushShadowPass(wgpu::RenderPassEncoder pass, int cascade) { auto &uboArena = currentUboArena(); if (cascade < 0 || cascade >= ShadowConfig::kCascades) return; + if (!mesh3dShadowPipeline) createShadowPipelines(); ensureUboArena(uboArena, uboArena.used + shadowCascadeDraws[cascade].size() * 8448); for (auto &d : shadowCascadeDraws[cascade]) { auto *gpuMesh = static_cast(d.mesh->gpuHandle); @@ -4068,6 +3699,7 @@ void Graphics::flushShadowPass(wgpu::RenderPassEncoder pass, int cascade) { void Graphics::flushGbufferPass(wgpu::RenderPassEncoder pass) { if (gbufferPassDraws.empty() || gbufferSlots.empty()) return; + if (!mesh3dGbufferPipeline) createGbufferPipelines(); auto &uboArena = currentUboArena(); ensureUboArena(uboArena, uboArena.used + gbufferPassDraws.size() * 8448); for (auto &d : gbufferPassDraws) { @@ -4134,6 +3766,7 @@ void Graphics::flushDecalPass(wgpu::RenderPassEncoder pass) { decalPassPending = false; return; } + if (!decalPipeline) createDecalPipeline(); auto &uboArena = currentUboArena(); ensureUboArena(uboArena, uboArena.used + decalPassDraws.size() * 512); GbufferSlot &gbuffer = gbufferSlots[lastGbufferSlot]; @@ -4263,7 +3896,8 @@ void Graphics::submitPendingDeferredPasses() { } void Graphics::flushVoxelDraws(wgpu::RenderPassEncoder pass, WGPUTextureFormat format) { - if (voxelDraws.empty() || !voxelRectPipeline) return; + if (voxelDraws.empty()) return; + if (!voxelRectPipeline) createVoxelPipelines(); auto &uboArena = currentUboArena(); ensureUboArena(uboArena, uboArena.used + voxelDraws.size() * 512); pass.SetPipeline(voxelRectPipeline); @@ -4663,7 +4297,7 @@ void Graphics::present() { sceneGpu.sampler = createLinearSampler(device); wgpu::BindGroup bg = makeTex2DBindGroup(&sceneGpu, nullptr); uint32_t offsets[1] = {0}; - pass.SetPipeline(texturedPipeline); + pass.SetPipeline(get2DTexturedPipeline(BlendMode::Alpha, false)); pass.SetBindGroup(0, bg, 1, offsets); pass.SetVertexBuffer(0, fullscreenQuadVb, 0, 4 * 32); pass.SetIndexBuffer(fullscreenQuadIb, wgpu::IndexFormat::Uint32, 0, 24); @@ -5061,7 +4695,7 @@ void Graphics::pumpReadback() { if (!pr.mapped) { // The map callback is delivered on the browser event loop between // frames (emdawnwebgpu callUserCallback), so no ASYNCIFY sleep is - // needed here — this runs from present() on the main loop. + // needed here �?this runs from present() on the main loop. #if defined(__EMSCRIPTEN__) wgpuInstanceProcessEvents(instance.Get()); #endif diff --git a/src/modules/graphics/webgpu/Graphics.h b/src/modules/graphics/webgpu/Graphics.h index 73e6d752..0ce0b8fc 100644 --- a/src/modules/graphics/webgpu/Graphics.h +++ b/src/modules/graphics/webgpu/Graphics.h @@ -14,6 +14,8 @@ #include #endif +#include "graphics/webgpu/Capabilities.h" + #include #include #include @@ -461,15 +463,16 @@ private: glm::vec4 extraParams{0.f}; }; - void createInstanceAndAdapter(); - void requestDevice(); void configureSurface(int width, int height); - void waitForAdapter(); - void waitForDevice(); void createDefaultTextures(); void createPipelineResources(); void create2DPipelines(); void createMesh3DPipelines(); + wgpu::RenderPipeline get2DColorPipeline(BlendMode blend, bool offscreen); + wgpu::RenderPipeline get2DTexturedPipeline(BlendMode blend, bool offscreen); + wgpu::RenderPipeline get2DLitPipeline(bool offscreen); + wgpu::RenderPipeline getMesh3DPipeline(BlendMode blend, bool depthWrite, + bool doubleSided, bool canvasTarget); void createMesh3DClusteredPipeline(); void createShadowPipelines(); void createGbufferPipelines(); @@ -588,13 +591,10 @@ private: wgpu::Device device; wgpu::Queue queue; wgpu::Surface surface; + Capabilities caps; 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; diff --git a/src/modules/graphics/webgpu/InitFlow.cpp b/src/modules/graphics/webgpu/InitFlow.cpp new file mode 100644 index 00000000..1f7be049 --- /dev/null +++ b/src/modules/graphics/webgpu/InitFlow.cpp @@ -0,0 +1,128 @@ +#include "graphics/webgpu/InitFlow.h" + +#include "common/Exception.h" + +#include +#include +#include + +#if defined(__EMSCRIPTEN__) +#include +#endif + +namespace eve::graphics::webgpu { + +InstanceDone InitFlow::createInstance() { + wgpu::Instance instance; +#if defined(__EMSCRIPTEN__) + instance = wgpu::CreateInstance(); +#else + // Native (Dawn) requires the TimedWaitAny instance feature so the + // ProcessEvents busy-wait below can drive the request callbacks. + const WGPUInstanceFeatureName requiredFeatures[] = {WGPUInstanceFeatureName_TimedWaitAny}; + WGPUInstanceDescriptor instanceDesc{}; + instanceDesc.requiredFeatureCount = 1; + instanceDesc.requiredFeatures = requiredFeatures; + instance = wgpu::CreateInstance(reinterpret_cast(&instanceDesc)); +#endif + if (!instance) throw Exception("WebGPU: wgpuCreateInstance failed"); + return InstanceDone{std::move(instance)}; +} + +AdapterDone InitFlow::requestAdapter(InstanceDone &&prev, const wgpu::Surface &compatibleSurface) { + wgpu::Instance &instance = prev.instance; + + struct Result { + wgpu::Adapter adapter; + std::string error; + std::atomic received{false}; + } result; + + WGPURequestAdapterOptions opts{}; + opts.compatibleSurface = compatibleSurface.Get(); + opts.powerPreference = WGPUPowerPreference_HighPerformance; + + WGPURequestAdapterCallbackInfo cbInfo{}; + cbInfo.nextInChain = nullptr; + cbInfo.mode = WGPUCallbackMode_AllowProcessEvents; + cbInfo.callback = [](WGPURequestAdapterStatus status, WGPUAdapter a, WGPUStringView msg, + void *userdata1, void * /*userdata2*/) { + auto *r = static_cast(userdata1); + if (status == WGPURequestAdapterStatus_Success && a) { + r->adapter = wgpu::Adapter(a); + } else { + r->error = msg.data ? std::string(msg.data, msg.length) : "unknown adapter error"; + } + r->received.store(true); + }; + cbInfo.userdata1 = &result; + cbInfo.userdata2 = nullptr; + + wgpuInstanceRequestAdapter(instance.Get(), &opts, cbInfo); + while (!result.received.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()); + } + if (!result.adapter) { + throw Exception("WebGPU: no adapter found (%s)", result.error.c_str()); + } + return AdapterDone{std::move(instance), std::move(result.adapter)}; +} + +DeviceDone InitFlow::requestDevice(AdapterDone &&prev) { + wgpu::Instance &instance = prev.instance; + wgpu::Adapter &adapter = prev.adapter; + + struct Result { + wgpu::Device device; + std::string error; + std::atomic received{false}; + } result; + + WGPUDeviceDescriptor devDesc{}; + devDesc.label = sv("eve_device"); + devDesc.uncapturedErrorCallbackInfo.callback = + [](WGPUDevice const *, WGPUErrorType type, WGPUStringView message, void *, void *) { + std::fprintf(stderr, "[webgpu] uncaptured error type=%d: %.*s\n", int(type), + int(message.length), message.data ? message.data : ""); + }; + + WGPURequestDeviceCallbackInfo cbInfo{}; + cbInfo.nextInChain = nullptr; + cbInfo.mode = WGPUCallbackMode_AllowProcessEvents; + cbInfo.callback = [](WGPURequestDeviceStatus status, WGPUDevice d, WGPUStringView msg, + void *userdata1, void * /*userdata2*/) { + auto *r = static_cast(userdata1); + if (status == WGPURequestDeviceStatus_Success && d) { + r->device = wgpu::Device(d); + } else { + r->error = msg.data ? std::string(msg.data, msg.length) : "unknown device error"; + } + r->received.store(true); + }; + cbInfo.userdata1 = &result; + cbInfo.userdata2 = nullptr; + + wgpuAdapterRequestDevice(adapter.Get(), &devDesc, cbInfo); + while (!result.received.load()) { +#if defined(__EMSCRIPTEN__) + emscripten_sleep(0); +#endif + wgpuInstanceProcessEvents(instance.Get()); + } + if (!result.device) { + throw Exception("WebGPU: device request failed (%s)", result.error.c_str()); + } + + wgpu::Queue queue = result.device.GetQueue(); + Capabilities caps; + caps.capture(adapter, result.device); + return DeviceDone{std::move(instance), std::move(adapter), std::move(result.device), + std::move(queue), std::move(caps)}; +} + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/InitFlow.h b/src/modules/graphics/webgpu/InitFlow.h new file mode 100644 index 00000000..aa73cfb5 --- /dev/null +++ b/src/modules/graphics/webgpu/InitFlow.h @@ -0,0 +1,65 @@ +#pragma once + +#include "graphics/webgpu/wgpu_types.h" +#include "graphics/webgpu/Capabilities.h" + +#include +#include +#include + +namespace eve::graphics::webgpu { + +// Stage objects are produced in a strict order: each request consumes the +// previous stage and yields the next one, so calling a later step without the +// earlier one (or out of order) is a compile error — the vk-bootstrap principle +// applied to WebGPU's async initialization. + +/** @brief Instance created, no adapter yet. */ +struct InstanceDone { + wgpu::Instance instance; +}; + +/** @brief Instance + adapter resolved. */ +struct AdapterDone { + wgpu::Instance instance; + wgpu::Adapter adapter; +}; + +/** @brief Instance + adapter + device + queue + capability snapshot. */ +struct DeviceDone { + wgpu::Instance instance; + wgpu::Adapter adapter; + wgpu::Device device; + wgpu::Queue queue; + Capabilities caps; +}; + +/** + * @brief Typed, blocking WebGPU initialization flow. + * + * Wraps the raw async request callbacks (RequestAdapter / RequestDevice) and + * the ProcessEvents busy-wait (preserving the browser Emscripten yield) behind + * typed stages, replacing the scattered std::atomic flags and + * waitForAdapter/waitForDevice helpers in Graphics.cpp. + */ +class InitFlow { +public: + /** @brief Create a wgpu Instance (native requests the TimedWaitAny feature). */ + static InstanceDone createInstance(); + + /** + * @brief Blocking adapter request. Drives ProcessEvents until the callback + * fires; throws eve::graphics::Exception when no adapter is found. + * @param prev the instance stage (only obtainable from createInstance()). + * @param compatibleSurface surface to prefer (may be null for headless). + */ + static AdapterDone requestAdapter(InstanceDone &&prev, const wgpu::Surface &compatibleSurface); + + /** + * @brief Blocking device request (plus queue + capability capture). + * Throws eve::graphics::Exception on failure. + */ + static DeviceDone requestDevice(AdapterDone &&prev); +}; + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/PipelineBuilder.cpp b/src/modules/graphics/webgpu/PipelineBuilder.cpp new file mode 100644 index 00000000..1022a220 --- /dev/null +++ b/src/modules/graphics/webgpu/PipelineBuilder.cpp @@ -0,0 +1,130 @@ +#include "graphics/webgpu/PipelineBuilder.h" + +#include "common/Exception.h" + +namespace eve::graphics::webgpu { + +PipelineBuilder &PipelineBuilder::vertexLayout(uint64_t stride, wgpu::VertexStepMode stepMode, + const wgpu::VertexAttribute *attrs, + size_t attrCount) { + wgpu::VertexBufferLayout l{}; + l.arrayStride = stride; + l.stepMode = stepMode; + l.attributeCount = attrCount; + l.attributes = attrs; + vertexBuffers_.push_back(l); + return *this; +} + +PipelineBuilder &PipelineBuilder::shader(wgpu::ShaderModule vert, const char *vertEntry, + wgpu::ShaderModule frag, const char *fragEntry) { + vertModule_ = vert; + fragModule_ = frag; + vertEntry_ = vertEntry; + fragEntry_ = fragEntry; + return *this; +} + +PipelineBuilder &PipelineBuilder::colorTarget(WGPUTextureFormat format, + eve::graphics::BlendMode mode) { + hasColorTarget_ = true; + colorFormat_ = static_cast(format); + blendMode_ = mode; + return *this; +} + +PipelineBuilder &PipelineBuilder::depth(WGPUTextureFormat format, wgpu::CompareFunction compare, + bool writeEnabled) { + hasDepth_ = true; + depthFormat_ = static_cast(format); + depthCompare_ = compare; + depthWrite_ = writeEnabled; + return *this; +} + +wgpu::RenderPipeline PipelineBuilder::build(const wgpu::Device &device) const { + if (!device) throw Exception("PipelineBuilder::build: null device"); + if (!vertModule_) throw Exception("PipelineBuilder::build: no vertex shader"); + if (!hasColorTarget_ && !hasDepth_) + throw Exception("PipelineBuilder::build: no color or depth target"); + + wgpu::RenderPipelineDescriptor d{}; + d.label = label_; + d.layout = layout_; + d.multisample.count = sampleCount_; + // C++ wrapper default mask = 0xFFFFFFFF (all samples); zero-init is safe. + + // Vertex state. + d.vertex.module = vertModule_; + d.vertex.entryPoint = vertEntry_; + d.vertex.bufferCount = static_cast(vertexBuffers_.size()); + d.vertex.buffers = vertexBuffers_.data(); + + // Primitive state. + d.primitive.topology = topology_; + d.primitive.frontFace = frontFace_; + d.primitive.cullMode = cullMode_; + d.primitive.stripIndexFormat = wgpu::IndexFormat::Undefined; + + // Depth/stencil. + wgpu::DepthStencilState ds{}; + if (hasDepth_) { + ds.format = depthFormat_; + ds.depthCompare = depthCompare_; + ds.depthWriteEnabled = depthWrite_; + d.depthStencil = &ds; + } + + // Fragment state. + wgpu::ColorTargetState target{}; + wgpu::FragmentState fs{}; + wgpu::BlendState blend{}; + if (hasColorTarget_) { + target.format = colorFormat_; + // C++ wrapper default writeMask = All; only override blend explicitly. + target.blend = nullptr; + switch (blendMode_) { + case eve::graphics::BlendMode::Alpha: + blend.color = {wgpu::BlendOperation::Add, wgpu::BlendFactor::SrcAlpha, + wgpu::BlendFactor::OneMinusSrcAlpha}; + blend.alpha = {wgpu::BlendOperation::Add, wgpu::BlendFactor::One, + wgpu::BlendFactor::OneMinusSrcAlpha}; + target.blend = &blend; + break; + case eve::graphics::BlendMode::Additive: + blend.color = {wgpu::BlendOperation::Add, wgpu::BlendFactor::SrcAlpha, + wgpu::BlendFactor::One}; + blend.alpha = {wgpu::BlendOperation::Add, wgpu::BlendFactor::One, + wgpu::BlendFactor::One}; + target.blend = &blend; + break; + case eve::graphics::BlendMode::Premultiplied: + blend.color = {wgpu::BlendOperation::Add, wgpu::BlendFactor::One, + wgpu::BlendFactor::OneMinusSrcAlpha}; + blend.alpha = {wgpu::BlendOperation::Add, wgpu::BlendFactor::One, + wgpu::BlendFactor::OneMinusSrcAlpha}; + target.blend = &blend; + break; + case eve::graphics::BlendMode::Multiply: + blend.color = {wgpu::BlendOperation::Add, wgpu::BlendFactor::Dst, + wgpu::BlendFactor::OneMinusSrcAlpha}; + blend.alpha = {wgpu::BlendOperation::Add, wgpu::BlendFactor::One, + wgpu::BlendFactor::OneMinusSrcAlpha}; + target.blend = &blend; + break; + case eve::graphics::BlendMode::Opaque: + default: + target.blend = nullptr; + break; + } + fs.module = fragModule_; + fs.entryPoint = fragEntry_; + fs.targetCount = 1; + fs.targets = ⌖ + d.fragment = &fs; + } + + return device.CreateRenderPipeline(&d); +} + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/PipelineBuilder.h b/src/modules/graphics/webgpu/PipelineBuilder.h new file mode 100644 index 00000000..6c5f77b0 --- /dev/null +++ b/src/modules/graphics/webgpu/PipelineBuilder.h @@ -0,0 +1,80 @@ +#pragma once + +#include "graphics/webgpu/wgpu_types.h" +#include "graphics/BlendMode.h" + +#include +#include + +namespace eve::graphics::webgpu { + +/** + * @brief Fluent builder for wgpu::RenderPipeline. + * + * Builds the C++ wrapper RenderPipelineDescriptor directly, whose zero-init + * already matches the WebGPU defaults (writeMask=All, sampleMask=0xFFFFFFFF), + * so the "default discards every fragment" traps in Graphics.cpp cannot occur. + * Mirrors vk-bootstrap's PipelineBuilder. + */ +class PipelineBuilder { +public: + PipelineBuilder() = default; + + PipelineBuilder &label(const char *l) { + label_ = l; + return *this; + } + PipelineBuilder &layout(wgpu::PipelineLayout l) { + layout_ = l; + return *this; + } + PipelineBuilder &vertexLayout(uint64_t stride, wgpu::VertexStepMode stepMode, + const wgpu::VertexAttribute *attrs, size_t attrCount); + PipelineBuilder &shader(wgpu::ShaderModule vert, const char *vertEntry, + wgpu::ShaderModule frag, const char *fragEntry); + PipelineBuilder &topology(wgpu::PrimitiveTopology t) { + topology_ = t; + return *this; + } + PipelineBuilder &cull(wgpu::CullMode c) { + cullMode_ = c; + return *this; + } + PipelineBuilder &frontFace(wgpu::FrontFace f) { + frontFace_ = f; + return *this; + } + /** @brief Set color target format + blend mode (Alpha default = no blend state override risk). */ + PipelineBuilder &colorTarget(WGPUTextureFormat format, eve::graphics::BlendMode mode); + PipelineBuilder &depth(WGPUTextureFormat format, wgpu::CompareFunction compare, + bool writeEnabled); + PipelineBuilder &sampleCount(uint32_t count) { + sampleCount_ = count; + return *this; + } + + /** @brief Create the pipeline. Throws on null device. */ + wgpu::RenderPipeline build(const wgpu::Device &device) const; + +private: + const char *label_ = "eve_pipeline"; + wgpu::PipelineLayout layout_; + std::vector vertexBuffers_; + wgpu::ShaderModule vertModule_; + wgpu::ShaderModule fragModule_; + const char *vertEntry_ = "vs_main"; + const char *fragEntry_ = "fs_main"; + wgpu::PrimitiveTopology topology_ = wgpu::PrimitiveTopology::TriangleList; + wgpu::CullMode cullMode_ = wgpu::CullMode::None; + wgpu::FrontFace frontFace_ = wgpu::FrontFace::CCW; + bool hasColorTarget_ = false; + wgpu::TextureFormat colorFormat_ = wgpu::TextureFormat::Undefined; + eve::graphics::BlendMode blendMode_ = eve::graphics::BlendMode::Alpha; + bool hasDepth_ = false; + wgpu::TextureFormat depthFormat_ = wgpu::TextureFormat::Undefined; + wgpu::CompareFunction depthCompare_ = wgpu::CompareFunction::Less; + bool depthWrite_ = true; + uint32_t sampleCount_ = 1; +}; + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/SamplerBuilder.cpp b/src/modules/graphics/webgpu/SamplerBuilder.cpp new file mode 100644 index 00000000..c511a2e6 --- /dev/null +++ b/src/modules/graphics/webgpu/SamplerBuilder.cpp @@ -0,0 +1,53 @@ +#include "graphics/webgpu/SamplerBuilder.h" + +#include "common/Exception.h" + +#include + +namespace eve::graphics::webgpu { + +SamplerBuilder SamplerBuilder::fromEngine(const eve::graphics::TextureSampler &s, + uint32_t mipLevels, float maxDeviceAnisotropy) { + SamplerBuilder b; + b.addrU_ = s.repeatU ? wgpu::AddressMode::Repeat : wgpu::AddressMode::ClampToEdge; + b.addrV_ = s.repeatV ? wgpu::AddressMode::Repeat : wgpu::AddressMode::ClampToEdge; + b.addrW_ = s.repeatW ? wgpu::AddressMode::Repeat : wgpu::AddressMode::ClampToEdge; + b.mag_ = s.mag == FilterMode::Nearest ? wgpu::FilterMode::Nearest : wgpu::FilterMode::Linear; + b.min_ = s.min == FilterMode::Nearest ? wgpu::FilterMode::Nearest : wgpu::FilterMode::Linear; + if (s.mipmap == MipmapMode::Disabled || mipLevels <= 1) { + b.mip_ = wgpu::MipmapFilterMode::Nearest; + b.lodMin_ = 0.f; + b.lodMax_ = 0.f; + } else { + b.mip_ = s.mipmap == MipmapMode::Nearest ? wgpu::MipmapFilterMode::Nearest + : wgpu::MipmapFilterMode::Linear; + b.lodMin_ = s.minLod; + b.lodMax_ = std::min(std::max(s.maxLod, 0.f), float(mipLevels - 1)); + if (b.lodMax_ < b.lodMin_) b.lodMax_ = b.lodMin_; + } + float aniso = s.maxAnisotropy > 1.f ? s.maxAnisotropy : 1.f; + b.aniso_ = std::min(std::max(aniso, 1.f), std::max(maxDeviceAnisotropy, 1.f)); + return b; +} + +wgpu::Sampler SamplerBuilder::build(const wgpu::Device &device) const { + if (!device) throw Exception("SamplerBuilder::build: null device"); + + // Build the C++ wrapper descriptor directly: its zero-init already matches + // the WebGPU defaults (no reinterpret_cast, no hand-rolled default masks). + wgpu::SamplerDescriptor d{}; + d.label = label_; + d.addressModeU = addrU_; + d.addressModeV = addrV_; + d.addressModeW = addrW_; + d.magFilter = mag_; + d.minFilter = min_; + d.mipmapFilter = mip_; + d.lodMinClamp = lodMin_; + d.lodMaxClamp = lodMax_; + d.maxAnisotropy = static_cast(aniso_); + if (hasCompare_) d.compare = compare_; + return device.CreateSampler(&d); +} + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/SamplerBuilder.h b/src/modules/graphics/webgpu/SamplerBuilder.h new file mode 100644 index 00000000..5be0741c --- /dev/null +++ b/src/modules/graphics/webgpu/SamplerBuilder.h @@ -0,0 +1,78 @@ +#pragma once + +#include "graphics/webgpu/wgpu_types.h" +#include "graphics/TextureSampler.h" + +#include + +namespace eve::graphics::webgpu { + +/** + * @brief Fluent builder for wgpu::Sampler. + * + * The constructor already writes WebGPU-legal defaults (clamp-to-edge, linear, + * no mips, anisotropy 1), so a sampler is always valid without extra calls — + * this mirrors vk-bootstrap's "builder default is a valid value" principle and + * removes the hand-written WGPUSamplerDescriptor zero-init + reinterpret_cast + * that made default-mask bugs possible in Graphics.cpp. + */ +class SamplerBuilder { +public: + /** @brief Defaults: clamp-to-edge, linear mag/min, no mip sampling, aniso 1. */ + SamplerBuilder() = default; + + /** @brief Start from an engine TextureSampler (mirrors Graphics::makeSampler). */ + static SamplerBuilder fromEngine(const eve::graphics::TextureSampler &s, uint32_t mipLevels, + float maxDeviceAnisotropy); + + SamplerBuilder &label(const char *l) { + label_ = l; + return *this; + } + SamplerBuilder &address(wgpu::AddressMode u, wgpu::AddressMode v, + wgpu::AddressMode w = wgpu::AddressMode::ClampToEdge) { + addrU_ = u; + addrV_ = v; + addrW_ = w; + return *this; + } + SamplerBuilder &filter(wgpu::FilterMode mag, wgpu::FilterMode min) { + mag_ = mag; + min_ = min; + return *this; + } + SamplerBuilder &mipmap(wgpu::MipmapFilterMode mip, float lodMin = 0.f, float lodMax = 1000.f) { + mip_ = mip; + lodMin_ = lodMin; + lodMax_ = lodMax; + return *this; + } + SamplerBuilder &compare(wgpu::CompareFunction cmp) { + compare_ = cmp; + hasCompare_ = true; + return *this; + } + SamplerBuilder &anisotropy(float maxAniso) { + aniso_ = maxAniso > 1.f ? maxAniso : 1.f; + return *this; + } + + /** @brief Create the sampler. Throws eve::graphics::Exception on null device. */ + wgpu::Sampler build(const wgpu::Device &device) const; + +private: + const char *label_ = "eve_sampler"; + wgpu::AddressMode addrU_ = wgpu::AddressMode::ClampToEdge; + wgpu::AddressMode addrV_ = wgpu::AddressMode::ClampToEdge; + wgpu::AddressMode addrW_ = wgpu::AddressMode::ClampToEdge; + wgpu::FilterMode mag_ = wgpu::FilterMode::Linear; + wgpu::FilterMode min_ = wgpu::FilterMode::Linear; + wgpu::MipmapFilterMode mip_ = wgpu::MipmapFilterMode::Nearest; + float lodMin_ = 0.f; + float lodMax_ = 0.f; + float aniso_ = 1.f; + bool hasCompare_ = false; + wgpu::CompareFunction compare_ = wgpu::CompareFunction::Always; +}; + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/graphics/webgpu/wgpu_types.h b/src/modules/graphics/webgpu/wgpu_types.h new file mode 100644 index 00000000..2ff12582 --- /dev/null +++ b/src/modules/graphics/webgpu/wgpu_types.h @@ -0,0 +1,29 @@ +#pragma once + +// Lightweight WebGPU helper types shared by the webgpu backend layer. +// This header only depends on the wgpu C++ wrapper (webgpu_cpp.h); it must not +// include any engine module headers (AGENTS.md layering rules). + +#if defined(__EMSCRIPTEN__) && __has_include() +#include +#else +#include +#endif + +#include +#include +#include + +namespace eve::graphics::webgpu { + +/** @brief Build a WGPUStringView from a C string (null-safe, length auto-computed). */ +inline WGPUStringView sv(const char *s) { + return WGPUStringView{s, s ? std::strlen(s) : 0}; +} + +/** @brief Build a WGPUStringView from a std::string. */ +inline WGPUStringView sv(const std::string &s) { + return WGPUStringView{s.data(), s.size()}; +} + +} // namespace eve::graphics::webgpu \ No newline at end of file diff --git a/src/modules/hd2d/Hd2d.cpp b/src/modules/hd2d/Hd2d.cpp new file mode 100644 index 00000000..fc27047f --- /dev/null +++ b/src/modules/hd2d/Hd2d.cpp @@ -0,0 +1,76 @@ +#include "hd2d/Hd2d.h" + +#include "common/Exception.h" +#include "graphics/Graphics.h" +#include "graphics/RenderSystem3D.h" + +#include + +namespace eve::hd2d { + +Hd2D::Hd2D() = default; +Hd2D::~Hd2D() = default; + +Module_IMPL(Hd2D, new Hd2D()); + +TileMap3D *Hd2D::newTileMap3D() { return new TileMap3D(); } + +Sprite3D *Hd2D::newSprite(graphics::Graphics *gfx) { + if (!gfx) throw eve::Exception("Hd2D.newSprite: null graphics"); + auto *sprite = new Sprite3D(); + sprite->buildQuad(gfx); + return sprite; +} + +void Hd2D::expose(ssq::Table &table) { + auto cls = table.addClass(name, Hd2D::create, false); + expose(cls); + + auto tile = table.addClass( + "TileMap3D", std::function([]() { return nullptr; }), true); + tile.addFunc("setSideDepth", &TileMap3D::setSideDepth); + tile.addFunc("getSideDepth", &TileMap3D::getSideDepth); + tile.addFunc("setHeightScale", &TileMap3D::setHeightScale); + tile.addFunc("getHeightScale", &TileMap3D::getHeightScale); + tile.addFunc("setWallUV", &TileMap3D::setWallUV); + tile.addFunc("setTint", &TileMap3D::setTint); + tile.addFunc("buildMesh", &TileMap3D::buildMesh); + tile.addFunc("buildRenderable", &TileMap3D::buildRenderable); + tile.addFunc("getTileCount", &TileMap3D::getTileCount); + + auto sprite = table.addClass( + "Sprite3D", std::function([]() { return nullptr; }), true); + sprite.addFunc("setTexture", &Sprite3D::setTexture); + sprite.addFunc("getTexture", &Sprite3D::getTexture); + sprite.addFunc("setFrame", &Sprite3D::setFrame); + sprite.addFunc("setFlipX", &Sprite3D::setFlipX); + sprite.addFunc("setFlipY", &Sprite3D::setFlipY); + sprite.addFunc("setFrameGrid", &Sprite3D::setFrameGrid); + sprite.addFunc("getFrameGridColumns", &Sprite3D::getFrameGridColumns); + sprite.addFunc("getFrameGridRows", &Sprite3D::getFrameGridRows); + sprite.addFunc("setFrameIndex", &Sprite3D::setFrameIndex); + sprite.addFunc("getFrameIndex", &Sprite3D::getFrameIndex); + sprite.addFunc("getFrameCount", &Sprite3D::getFrameCount); + sprite.addFunc("play", &Sprite3D::play); + sprite.addFunc("stop", &Sprite3D::stop); + sprite.addFunc("isPlaying", &Sprite3D::isPlaying); + sprite.addFunc("update", &Sprite3D::update); + sprite.addFunc("setPosition", &Sprite3D::setPosition); + sprite.addFunc("getPositionX", &Sprite3D::getPositionX); + sprite.addFunc("getPositionY", &Sprite3D::getPositionY); + sprite.addFunc("getPositionZ", &Sprite3D::getPositionZ); + sprite.addFunc("setSize", &Sprite3D::setSize); + sprite.addFunc("getWidth", &Sprite3D::getWidth); + sprite.addFunc("getHeight", &Sprite3D::getHeight); + sprite.addFunc("setTint", &Sprite3D::setTint); + sprite.addFunc("setVisible", &Sprite3D::setVisible); + sprite.addFunc("getVisible", &Sprite3D::getVisible); +} + +void Hd2D::expose(ssq::Class &cls) { + cls.addFunc("getName", &Hd2D::getName); + cls.addFunc("newTileMap3D", &Hd2D::newTileMap3D); + cls.addFunc("newSprite", &Hd2D::newSprite); +} + +} // namespace eve::hd2d \ No newline at end of file diff --git a/src/modules/hd2d/Hd2d.h b/src/modules/hd2d/Hd2d.h new file mode 100644 index 00000000..005a6d78 --- /dev/null +++ b/src/modules/hd2d/Hd2d.h @@ -0,0 +1,220 @@ +#pragma once + +// HD-2D module: renders 2D tilemaps and 2D sprite characters inside the 3D +// scene. Both techniques layer on top of the existing graphics 3D pipeline +// (GBuffer + CSM shadows): +// +// * TileMap3D -- extrudes a map::TileLayer into a 3D relief mesh. Every +// non-empty tile becomes a box whose top face shows the +// tile's atlas region and whose side walls extrude down to +// the configured depth (or to the per-tile elevation read +// from tile metadata). Handed to a graphics::Renderable3D so +// it picks up depth, shadows and GBuffer automatically. +// * Sprite3D -- a camera-facing billboard quad in the 3D world carrying a +// 2D texture (a character / animation frame). It is an ECS +// graphics::Renderable3D oriented toward the camera each +// frame (cylindrical billboard, upright), so it composites +// into the forward pass and casts shadows like any mesh. +// Supports a sprite-sheet frame grid so existing 2D +// animations play in 3D. +// +// Script binding: eve.Hd2d (slot hd2d). See module_manifest.cmake. + +#include "common/Module.h" + +#include + +#include +#include +#include + +namespace eve::graphics { +class Camera3D; +class Graphics; +class Mesh; +class Renderable3D; +class Texture; +} // namespace eve::graphics +namespace eve::map { class TileLayer; } + +namespace eve::hd2d { + +/** + * @brief Extrudes a 2D tile layer into a 3D terrain mesh (HD-2D ground). + * + * Coordinates map the layer's tileToWorld() X/Z plane onto world X/Z; the box + * footprint per tile is tileW × tileH world units. The top face of every + * non-empty tile is textured with the tile's atlas region from the layer's + * tileset; the side walls sample a configurable wall region of the same atlas + * (default: a small region at the atlas origin). Per-tile elevation is read + * from the tile's "height" custom data (0 when absent); each tile spans from + * `elevation` down to `elevation - sideDepth`. + */ +class TileMap3D { +public: + TileMap3D() = default; + TileMap3D(const TileMap3D &) = delete; + TileMap3D &operator=(const TileMap3D &) = delete; + + /** @brief Downward side-wall depth in world units (default 6). */ + void setSideDepth(float depth); + float getSideDepth() const; + /** @brief World units per unit of tile "height" metadata (default 1). */ + void setHeightScale(float scale); + float getHeightScale() const; + /** @brief UV sub-rectangle of the atlas used for extruded side walls. */ + void setWallUV(float u0, float v0, float u1, float v1); + /** @brief Tint multiplier applied to the whole terrain. */ + void setTint(float r, float g, float b, float a = 1.f); + + /** + * @brief Build and upload the terrain mesh from a tile layer. + * @param gfx Active Graphics (owns the returned mesh). + * @param layer Non-null TileLayer; its tileset must be bound. + * @return Uploaded mesh (owner = gfx). Empty tiles produce a null mesh. + */ + graphics::Mesh *buildMesh(graphics::Graphics *gfx, map::TileLayer *layer); + + /** + * @brief Build a scene-ready Renderable3D carrying the extruded terrain. + * The returned entity is already registered in the ECS and will draw once a + * Camera3D is active; caller sets its position/rotation/scale. + */ + graphics::Renderable3D *buildRenderable(graphics::Graphics *gfx, map::TileLayer *layer); + + /** @brief Number of box tiles baked by the last build. */ + int getTileCount() const { return tileCount_; } + +private: + float sideDepth_ = 6.f; + float heightScale_ = 1.f; + float wallU0_ = 0.f, wallV0_ = 0.f, wallU1_ = 0.05f, wallV1_ = 0.05f; + float tintR_ = 1.f, tintG_ = 1.f, tintB_ = 1.f, tintA_ = 1.f; + int tileCount_ = 0; +}; + +/** + * @brief Camera-facing 2D sprite billboard rendered inside the 3D scene. + * + * A unit quad is drawn through the alpha-cutout G-buffer + shadow extra-drawer + * path (graphics/RenderSystem3D.h), so transparent sprite pixels discard and + * the sprite casts a silhouette shadow like other billboard/card geometry. The + * billboard is oriented toward the active camera each frame. + * + * 2D sprite-sheet animation: setFrameGrid(cols, rows) + setFrameIndex / play + * advance a frame index; update(dt) steps the clock. This lets existing 2D + * character animations run in 3D. + */ +class Sprite3D { +public: + Sprite3D(); + ~Sprite3D(); + Sprite3D(const Sprite3D &) = delete; + Sprite3D &operator=(const Sprite3D &) = delete; + + /** @brief Albedo texture (a sprite frame / sheet / animation image). */ + void setTexture(graphics::Texture *texture); + graphics::Texture *getTexture() const; + + /** @brief Direct atlas sub-rect frame selection (0..1 UV). */ + void setFrame(float u0, float v0, float u1, float v1); + void getFrame(float &u0, float &v0, float &u1, float &v1) const; + /** @brief Flip the sampled frame horizontally / vertically. */ + void setFlipX(bool flip); + void setFlipY(bool flip); + + /** @brief Configure a sprite-sheet frame grid (cell frame 0 = top-left). */ + void setFrameGrid(int columns, int rows); + int getFrameGridColumns() const; + int getFrameGridRows() const; + /** @brief Jump to a grid frame index; wraps within the grid. */ + void setFrameIndex(int index); + int getFrameIndex() const; + /** @brief Total grid frames. */ + int getFrameCount() const; + /** + * @brief Run a frame animation. + * @param start First grid frame. + * @param end Inclusive last grid frame. + * @param fps Frames per second (must be > 0). + */ + void play(int start, int end, float fps); + void stop(); + bool isPlaying() const; + /** + * @brief Advance the animation clock and re-orient the billboard toward the + * attached camera. Call once per frame. + * @param dt Seconds elapsed since the last update. + */ + void update(float dt); + + /** @brief Camera the billboard faces; null disables auto-orientation. */ + void setCamera(graphics::Camera3D *camera); + + /** @brief World position (billboard center). */ + void setPosition(float x, float y, float z); + float getPositionX() const; + float getPositionY() const; + float getPositionZ() const; + /** @brief Billboard size in world units. */ + void setSize(float width, float height); + float getWidth() const; + float getHeight() const; + /** @brief Color multiplier (alpha participates in cutout discards). */ + void setTint(float r, float g, float b, float a = 1.f); + void setVisible(bool visible); + bool getVisible() const; + + graphics::Mesh *quadMesh() const { return quad_; } + +private: + friend class Hd2D; + struct Anim { + int start = 0, end = 0; + float fps = 12.f; + float clock = 0.f; + bool playing = false; + }; + + void buildQuad(graphics::Graphics *gfx); + void updateFrameUv(); + void orientToCamera(); + + graphics::Graphics *gfx_ = nullptr; + graphics::Texture *texture_ = nullptr; + graphics::Mesh *quad_ = nullptr; + graphics::Renderable3D *renderable_ = nullptr; + graphics::Camera3D *camera_ = nullptr; + + // frame UV + float u0_ = 0.f, v0_ = 0.f, u1_ = 1.f, v1_ = 1.f; + bool flipX_ = false, flipY_ = false; + + // grid + int gridCols_ = 1, gridRows_ = 1; + int frameIndex_ = 0; + Anim anim_; + + // placement + float x_ = 0.f, y_ = 0.f, z_ = 0.f; + float width_ = 1.f, height_ = 1.f; + float tintR_ = 1.f, tintG_ = 1.f, tintB_ = 1.f, tintA_ = 1.f; + bool visible_ = true; +}; + +/** + * @brief HD-2D module: tilemap-to-3D extrusion + 2D sprites as 3D billboards. + */ +class Hd2D : public Module { +public: + Module_REG(Hd2D); + Hd2D(); + ~Hd2D() override; + + /** @brief Create a tilemap-to-3D builder. */ + TileMap3D *newTileMap3D(); + /** @brief Create a 3D billboard sprite bound to the given Graphics. */ + Sprite3D *newSprite(graphics::Graphics *gfx); +}; + +} // namespace eve::hd2d \ No newline at end of file diff --git a/src/modules/hd2d/Sprite3D.cpp b/src/modules/hd2d/Sprite3D.cpp new file mode 100644 index 00000000..fc60cd3d --- /dev/null +++ b/src/modules/hd2d/Sprite3D.cpp @@ -0,0 +1,211 @@ +#include "hd2d/Hd2d.h" + +#include "common/Exception.h" +#include "graphics/Graphics.h" +#include "graphics/Mesh.h" +#include "graphics/RenderSystem3D.h" +#include "graphics/Texture.h" + +#include +#include +#include +#include +#include + +namespace eve::hd2d { + +namespace { + +// Unit quad in the XY plane, normal +Z, full 0..1 UV. Spans [-0.5,0.5] so the +// Renderable3D scale maps it to the requested world-space size. +const std::vector kQuadPos = {-0.5f, -0.5f, 0.f, 0.5f, -0.5f, 0.f, + 0.5f, 0.5f, 0.f, -0.5f, 0.5f, 0.f}; +const std::vector kQuadNrm = {0.f, 0.f, 1.f, 0.f, 0.f, 1.f, + 0.f, 0.f, 1.f, 0.f, 0.f, 1.f}; +const std::vector kQuadIdx = {0, 1, 2, 0, 2, 3}; +const std::vector kQuadUv = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f}; + +} // namespace + +// --------------------------------------------------------------------------- +// Sprite3D +// --------------------------------------------------------------------------- + +Sprite3D::Sprite3D() = default; +Sprite3D::~Sprite3D() { + if (renderable_) renderable_->setVisible(false); +} + +void Sprite3D::buildQuad(graphics::Graphics *gfx) { + if (!gfx) throw eve::Exception("Sprite3D: null graphics"); + gfx_ = gfx; + quad_ = gfx->newMeshFromArrays(kQuadPos.data(), kQuadNrm.data(), kQuadUv.data(), + int(kQuadPos.size() / 3), kQuadIdx.data(), + int(kQuadIdx.size())); + if (!quad_) throw eve::Exception("Sprite3D: quad mesh creation failed"); + + renderable_ = graphics::Renderable3D::create(); + if (!renderable_) throw eve::Exception("Sprite3D: Renderable3D create failed"); + renderable_->setMesh(quad_); + // 2D sprites are self-lit (HD-2D characters), not shaded by 3D lights. + renderable_->setReceiveLight(false); + renderable_->setScale(width_, height_, 1.f); + renderable_->setPosition(x_, y_, z_); + renderable_->setTint(tintR_, tintG_, tintB_, tintA_); + renderable_->setVisible(visible_); + updateFrameUv(); +} + +void Sprite3D::updateFrameUv() { + if (!gfx_ || !quad_) return; + std::vector uv(8); + for (int i = 0; i < 4; ++i) { + float u = (i == 0 || i == 3) ? u0_ : u1_; + float v = (i < 2) ? v0_ : v1_; + if (flipX_) u = (u0_ + u1_) - u; + if (flipY_) v = (v0_ + v1_) - v; + uv[size_t(i) * 2u] = u; + uv[size_t(i) * 2u + 1u] = v; + } + // Positions are required by updateMeshVertices; pass the unchanged base. + gfx_->updateMeshVertices(quad_, kQuadPos.data(), kQuadNrm.data(), uv.data(), + int(kQuadPos.size() / 3), nullptr, 0); +} + +void Sprite3D::setTexture(graphics::Texture *texture) { + texture_ = texture; + if (renderable_) renderable_->setTexture(texture); +} +graphics::Texture *Sprite3D::getTexture() const { return texture_; } + +void Sprite3D::setFrame(float u0, float v0, float u1, float v1) { + u0_ = u0; + v0_ = v0; + u1_ = u1; + v1_ = v1; + updateFrameUv(); +} +void Sprite3D::getFrame(float &u0, float &v0, float &u1, float &v1) const { + u0 = u0_; + v0 = v0_; + u1 = u1_; + v1 = v1_; +} +void Sprite3D::setFlipX(bool flip) { + flipX_ = flip; + updateFrameUv(); +} +void Sprite3D::setFlipY(bool flip) { + flipY_ = flip; + updateFrameUv(); +} + +void Sprite3D::setFrameGrid(int columns, int rows) { + if (columns <= 0 || rows <= 0) + throw eve::Exception("Sprite3D.setFrameGrid: columns and rows must be > 0"); + gridCols_ = columns; + gridRows_ = rows; + frameIndex_ = std::min(frameIndex_, gridCols_ * gridRows_ - 1); + setFrameIndex(frameIndex_); +} +int Sprite3D::getFrameGridColumns() const { return gridCols_; } +int Sprite3D::getFrameGridRows() const { return gridRows_; } +int Sprite3D::getFrameCount() const { return gridCols_ * gridRows_; } + +void Sprite3D::setFrameIndex(int index) { + frameIndex_ = std::max(0, std::min(index, gridCols_ * gridRows_ - 1)); + const int cx = frameIndex_ % gridCols_; + const int cy = frameIndex_ / gridCols_; + const float u0 = float(cx) / float(gridCols_); + const float u1 = float(cx + 1) / float(gridCols_); + const float v0 = float(cy) / float(gridRows_); + const float v1 = float(cy + 1) / float(gridRows_); + setFrame(u0, v0, u1, v1); +} +int Sprite3D::getFrameIndex() const { return frameIndex_; } + +void Sprite3D::play(int start, int end, float fps) { + if (start < 0 || end < start || end >= gridCols_ * gridRows_) + throw eve::Exception("Sprite3D.play: frame range [%d,%d] out of grid", start, end); + if (fps <= 0.f) throw eve::Exception("Sprite3D.play: fps must be > 0"); + anim_ = {start, end, fps, 0.f, true}; + setFrameIndex(start); +} +void Sprite3D::stop() { anim_.playing = false; } +bool Sprite3D::isPlaying() const { return anim_.playing; } + +void Sprite3D::update(float dt) { + if (anim_.playing && anim_.fps > 0.f) { + anim_.clock += dt; + const float frameDur = 1.f / anim_.fps; + if (anim_.clock >= frameDur) { + int steps = int(anim_.clock / frameDur); + anim_.clock -= float(steps) * frameDur; + const int span = anim_.end - anim_.start + 1; + if (span > 0) { + frameIndex_ = anim_.start + (frameIndex_ - anim_.start + steps) % span; + setFrameIndex(frameIndex_); + } + } + } + orientToCamera(); +} + +void Sprite3D::setCamera(graphics::Camera3D *camera) { + camera_ = camera; + orientToCamera(); +} + +void Sprite3D::orientToCamera() { + if (!renderable_ || !camera_) return; + const auto &cam = *camera_->data(); + const glm::vec3 eye(cam.eyeX, cam.eyeY, cam.eyeZ); + const glm::vec3 pos(x_, y_, z_); + // The billboard quad's front face is -Z, so it faces the camera when its + // +Z points away from the eye. Cylindrical billboard: rotate around world Y + // only, keeping the sprite upright (no pitch) so it faces the camera + // squarely while standing vertical. + glm::vec3 away = pos - eye; + if (glm::dot(away, away) < 1e-6f) away = glm::vec3(0.f, 0.f, 1.f); + away = glm::normalize(away); + const float yaw = std::atan2(away.x, away.z); + renderable_->setRotation(yaw, 0.f, 0.f); +} + +void Sprite3D::setPosition(float x, float y, float z) { + x_ = x; + y_ = y; + z_ = z; + if (renderable_) { + renderable_->setPosition(x, y, z); + orientToCamera(); + } +} +float Sprite3D::getPositionX() const { return x_; } +float Sprite3D::getPositionY() const { return y_; } +float Sprite3D::getPositionZ() const { return z_; } + +void Sprite3D::setSize(float width, float height) { + if (width <= 0.f || height <= 0.f) + throw eve::Exception("Sprite3D.setSize: width and height must be > 0"); + width_ = width; + height_ = height; + if (renderable_) renderable_->setScale(width, height, 1.f); +} +float Sprite3D::getWidth() const { return width_; } +float Sprite3D::getHeight() const { return height_; } + +void Sprite3D::setTint(float r, float g, float b, float a) { + tintR_ = std::max(0.f, std::min(1.f, r)); + tintG_ = std::max(0.f, std::min(1.f, g)); + tintB_ = std::max(0.f, std::min(1.f, b)); + tintA_ = std::max(0.f, std::min(1.f, a)); + if (renderable_) renderable_->setTint(tintR_, tintG_, tintB_, tintA_); +} +void Sprite3D::setVisible(bool visible) { + visible_ = visible; + if (renderable_) renderable_->setVisible(visible); +} +bool Sprite3D::getVisible() const { return visible_; } + +} // namespace eve::hd2d \ No newline at end of file diff --git a/src/modules/hd2d/TileMap3D.cpp b/src/modules/hd2d/TileMap3D.cpp new file mode 100644 index 00000000..df7b9f47 --- /dev/null +++ b/src/modules/hd2d/TileMap3D.cpp @@ -0,0 +1,185 @@ +#include "hd2d/Hd2d.h" + +#include "common/Exception.h" +#include "graphics/Graphics.h" +#include "graphics/Mesh.h" +#include "graphics/RenderSystem3D.h" +#include "map/TileLayer.h" + +#include +#include +#include +#include +#include + +namespace eve::hd2d { +namespace { + +constexpr float kPi = 3.14159265358979323846f; + +/** + * Atlas UV rectangle (0..1) for a global tile id, honoring tileset layout + * (columns / margin / spacing / tile size) against the atlas texture size. + * Falls back to a full-tile UV when the texture size is unknown. + */ +void atlasUvForGid(const map::TileLayer::Tileset &set, uint32_t gid, float &u0, float &v0, + float &u1, float &v1) { + const int cols = std::max(1, set.columns); + const int tileW = std::max(1, set.tileW); + const int tileH = std::max(1, set.tileH); + const int margin = std::max(0, set.margin); + const int spacing = std::max(0, set.spacing); + const int cell = int(gid) - set.firstGid; + if (cell < 0) { + u0 = 0.f; + v0 = 0.f; + u1 = 1.f; + v1 = 1.f; + return; + } + const int cx = cell % cols; + const int cy = cell / cols; + const float texW = set.texture ? float(std::max(1, set.texture->getWidth())) : float(tileW); + const float texH = set.texture ? float(std::max(1, set.texture->getHeight())) : float(tileH); + const float px0 = float(margin + cx * (tileW + spacing)); + const float py0 = float(margin + cy * (tileH + spacing)); + u0 = px0 / texW; + v0 = py0 / texH; + u1 = (px0 + float(tileW)) / texW; + v1 = (py0 + float(tileH)) / texH; +} + +} // namespace + +// --------------------------------------------------------------------------- +// TileMap3D +// --------------------------------------------------------------------------- + +void TileMap3D::setSideDepth(float depth) { + if (depth < 0.f) throw eve::Exception("TileMap3D.setSideDepth: depth must be >= 0"); + sideDepth_ = depth; +} +float TileMap3D::getSideDepth() const { return sideDepth_; } +void TileMap3D::setHeightScale(float scale) { + if (scale < 0.f) throw eve::Exception("TileMap3D.setHeightScale: scale must be >= 0"); + heightScale_ = scale; +} +float TileMap3D::getHeightScale() const { return heightScale_; } +void TileMap3D::setWallUV(float u0, float v0, float u1, float v1) { + wallU0_ = u0; + wallV0_ = v0; + wallU1_ = u1; + wallV1_ = v1; +} +void TileMap3D::setTint(float r, float g, float b, float a) { + tintR_ = std::max(0.f, std::min(1.f, r)); + tintG_ = std::max(0.f, std::min(1.f, g)); + tintB_ = std::max(0.f, std::min(1.f, b)); + tintA_ = std::max(0.f, std::min(1.f, a)); +} + +graphics::Mesh *TileMap3D::buildMesh(graphics::Graphics *gfx, map::TileLayer *layer) { + if (!gfx) throw eve::Exception("TileMap3D.buildMesh: null graphics"); + if (!layer) throw eve::Exception("TileMap3D.buildMesh: null tile layer"); + + const int mapW = layer->getMapWidth(); + const int mapH = layer->getMapHeight(); + if (mapW <= 0 || mapH <= 0) throw eve::Exception("TileMap3D.buildMesh: empty tile layer"); + + const float tileW = std::max(1.f, layer->getTileWidth()); + const float tileH = std::max(1.f, layer->getTileHeight()); + const float hx = tileW * 0.5f; + const float hz = tileH * 0.5f; + + auto set = layer->tileset(); // ecs::ComponentRef; always valid + const bool haveSet = set->texture != nullptr && set->columns > 0; + + std::vector pos; + std::vector nrm; + std::vector uv; + std::vector idx; + + // Unit-cube face topology (RH Y-up, outward CCW): faces = -Z,+Z,-X,+X,-Y,+Y. + // Face 5 (+Y) is the textured top; faces 0..4 are extruded side walls. + static const int kQuads[6][4] = { + {0, 1, 2, 3}, {5, 4, 7, 6}, {4, 0, 3, 7}, + {1, 5, 6, 2}, {4, 5, 1, 0}, {3, 2, 6, 7}, + }; + static const glm::vec3 kFaces[6] = { + {0.f, 0.f, -1.f}, {0.f, 0.f, 1.f}, {-1.f, 0.f, 0.f}, + {1.f, 0.f, 0.f}, {0.f, -1.f, 0.f}, {0.f, 1.f, 0.f}, + }; + + const auto pushBoxTile = [&](float cx, float cz, float topY, float botY, float tu0, float tv0, + float tu1, float tv1) { + // Corners (index matches kQuads[kFace]). + const glm::vec3 corners[8] = { + {cx - hx, botY, cz - hz}, {cx + hx, botY, cz - hz}, {cx + hx, topY, cz - hz}, + {cx - hx, topY, cz - hz}, {cx - hx, botY, cz + hz}, {cx + hx, botY, cz + hz}, + {cx + hx, topY, cz + hz}, {cx - hx, topY, cz + hz}, + }; + const float sideU0 = wallU0_, sideV0 = wallV0_, sideU1 = wallU1_, sideV1 = wallV1_; + for (int f = 0; f < 6; ++f) { + const uint32_t base = uint32_t(pos.size() / 3); + const bool top = (f == 5); + const float su0 = top ? tu0 : sideU0; + const float sv0 = top ? tv0 : sideV0; + const float su1 = top ? tu1 : sideU1; + const float sv1 = top ? tv1 : sideV1; + const float us[4] = {su0, su1, su1, su0}; + const float vs[4] = {sv0, sv0, sv1, sv1}; + const glm::vec3 &n = kFaces[f]; + for (int k = 0; k < 4; ++k) { + const glm::vec3 &p = corners[kQuads[f][k]]; + pos.insert(pos.end(), {p.x, p.y, p.z}); + nrm.insert(nrm.end(), {n.x, n.y, n.z}); + uv.insert(uv.end(), {us[k], vs[k]}); + } + idx.insert(idx.end(), {base, base + 1, base + 2, base, base + 2, base + 3}); + } + }; + + tileCount_ = 0; + + for (int ty = 0; ty < mapH; ++ty) { + for (int tx = 0; tx < mapW; ++tx) { + const uint32_t gid = eve::map::tileGid(uint32_t(layer->getTile(tx, ty))); + if (gid == 0) continue; + + // World X/Z from the layer's 2D projection; elevation from metadata. + const float wx = layer->tileToWorldX(tx, ty); + const float wz = layer->tileToWorldY(tx, ty); + const float elev = layer->getTileDataNumber(int(gid), "height") * heightScale_; + const float topY = elev; + const float botY = elev - sideDepth_; + + float tu0 = 0.f, tv0 = 0.f, tu1 = 1.f, tv1 = 1.f; + if (haveSet) atlasUvForGid(*set, gid, tu0, tv0, tu1, tv1); + + pushBoxTile(wx, wz, topY, botY, tu0, tv0, tu1, tv1); + ++tileCount_; + } + } + + if (pos.empty()) return nullptr; + return gfx->newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(pos.size() / 3), + idx.data(), int(idx.size())); +} + +graphics::Renderable3D *TileMap3D::buildRenderable(graphics::Graphics *gfx, + map::TileLayer *layer) { + if (!gfx) throw eve::Exception("TileMap3D.buildRenderable: null graphics"); + if (!layer) throw eve::Exception("TileMap3D.buildRenderable: null tile layer"); + + graphics::Mesh *mesh = buildMesh(gfx, layer); + if (!mesh) throw eve::Exception("TileMap3D.buildRenderable: no non-empty tiles"); + + auto *renderable = graphics::Renderable3D::create(); + if (!renderable) throw eve::Exception("TileMap3D.buildRenderable: Renderable3D create failed"); + renderable->setMesh(mesh); + if (layer->tileset()->texture) renderable->setTexture(layer->tileset()->texture); + renderable->setTint(tintR_, tintG_, tintB_, tintA_); + return renderable; +} + +} // namespace eve::hd2d \ No newline at end of file diff --git a/src/modules/map/TileSystem.cpp b/src/modules/map/TileSystem.cpp index dd60be97..3f6b8e19 100644 --- a/src/modules/map/TileSystem.cpp +++ b/src/modules/map/TileSystem.cpp @@ -7,6 +7,7 @@ #include "graphics/Canvas.h" #include "filesystem/Filesystem.h" #include "common/Module.h" +#include "common/Profile.h" #include #include @@ -262,6 +263,7 @@ int TileRenderSystem::lastVisitedChunkCount() { return gLastVisitedChunkCount; } int TileRenderSystem::lastVisitedCellCount() { return gLastVisitedCellCount; } void TileRenderSystem::update(float dt) { + EV_PROFILE_MODULE("map", "TileRenderSystem::update"); if (dt > 0.f && std::isfinite(dt)) gAnimationTimeMs += double(dt) * 1000.0; } diff --git a/src/modules/particles/Particles.cpp b/src/modules/particles/Particles.cpp index 4fe67b36..7f2bc507 100644 --- a/src/modules/particles/Particles.cpp +++ b/src/modules/particles/Particles.cpp @@ -1,5 +1,6 @@ #include "particles/Particles.h" #include "common/Module.h" +#include "common/Profile.h" #include "data/DataModule.h" #include "data/JsonDocument.h" #include "filesystem/FileData.h" @@ -80,6 +81,7 @@ ParticleEffect* Particles::newEffectFromFile(const std::string& path) { } void Particles::update(float dt) { + EV_PROFILE_MODULE("particles", "Particles::update"); ParticleConfigSystem::poll(); ParticleSimSystem::update(dt); ParticleLightSystem::update(); diff --git a/src/modules/physics/World.cpp b/src/modules/physics/World.cpp index 557f31b7..7ec73e42 100644 --- a/src/modules/physics/World.cpp +++ b/src/modules/physics/World.cpp @@ -1,10 +1,11 @@ -#include "physics/World.h" +#include "physics/World.h" #include "physics/Body.h" #include "physics/Fixture.h" #include "physics/SimulationBackend.h" #include "common/Exception.h" #include "platform_event/PlatformEvent.h" +#include "common/Profile.h" #include @@ -240,7 +241,7 @@ void World::destroy() { if (destroyed_) return; destroyed_ = true; - // Copy sets — Body/Fixture destructors erase from them. + // Copy sets 鈥?Body/Fixture destructors erase from them. std::vector bodies(bodies_.begin(), bodies_.end()); for (Body *b : bodies) { if (b) { @@ -320,6 +321,7 @@ bool World::pointProbe(float x, float y, float radius, ClothContact *out) const void World::update(float dt) { updateFull(dt, 8, 3); } void World::updateFull(float dt, int velocityIterations, int positionIterations) { + EV_PROFILE_MODULE("physics", "World::update"); auto legacyStep = makeLegacyStep(dt, simulationTick_); if (!legacyStep) { legacyStep.ignore("legacy World::updateFull cannot return a structured error"); diff --git a/src/modules/physics/World3D.cpp b/src/modules/physics/World3D.cpp index e684985f..2bfa974c 100644 --- a/src/modules/physics/World3D.cpp +++ b/src/modules/physics/World3D.cpp @@ -6,6 +6,7 @@ #include "common/Exception.h" #include "platform_event/PlatformEvent.h" +#include "common/Profile.h" #include @@ -632,6 +633,7 @@ void World3D::forgetJoint(Joint3D *joint) { void World3D::update(float dt) { updateFull(dt, 4); } void World3D::updateFull(float dt, int subStepCount) { + EV_PROFILE_MODULE("physics", "World3D::update"); auto legacyStep = makeLegacyStep(dt, simulationTick_); if (!legacyStep) { legacyStep.ignore("legacy World3D::updateFull cannot return a structured error"); diff --git a/src/modules/platform_event/sdl/Event.cpp b/src/modules/platform_event/sdl/Event.cpp index c7fdac9f..421e0246 100644 --- a/src/modules/platform_event/sdl/Event.cpp +++ b/src/modules/platform_event/sdl/Event.cpp @@ -4,6 +4,7 @@ #include "common/Capability.h" #include "common/Exception.h" #include "common/Module.h" +#include "common/Profile.h" #include "common/config.h" #include "platform_event/PlatformEventSink.h" @@ -30,6 +31,7 @@ Event::~Event() void Event::pump() { + EV_PROFILE_MODULE("event", "Event::pump"); SDL_Event e; while (SDL_PollEvent(&e)) diff --git a/src/modules/procgen/Biome.cpp b/src/modules/procgen/Biome.cpp new file mode 100644 index 00000000..823f668d --- /dev/null +++ b/src/modules/procgen/Biome.cpp @@ -0,0 +1,171 @@ +#include "procgen/Biome.h" + +#include +#include + +namespace eve::procgen { +namespace { + +uint32_t mixBiome(uint32_t value) { + value += 0x9e3779b9u; + value = (value ^ (value >> 16u)) * 0x21f0aaadu; + value = (value ^ (value >> 15u)) * 0x735a2d97u; + return value ^ (value >> 15u); +} + +float biomeUnit(uint32_t seed) { return float(mixBiome(seed) >> 8u) * (1.f / 16777216.f); } + +} // namespace + +void BiomeRules::clear() { + layers_.clear(); + exclusions_.clear(); + error_.clear(); + lastCandidateCount_ = 0; + lastOutputCount_ = 0; +} + +bool BiomeRules::addLayer(const std::string& name, SpatialData* spatial, int priority, + float density) { + if (name.empty() || !spatial || hasLayer(name)) return false; + BiomeLayerRule layer; + layer.name = name; + layer.priority = priority; + layer.density = std::clamp(density, 0.f, 1.f); + layer.spatial = std::make_shared(*spatial); + layers_.push_back(std::move(layer)); + return true; +} + +bool BiomeRules::removeLayer(const std::string& name) { + const auto found = std::find_if(layers_.begin(), layers_.end(), + [&](const BiomeLayerRule& layer) { return layer.name == name; }); + if (found == layers_.end()) return false; + layers_.erase(found); + return true; +} + +bool BiomeRules::hasLayer(const std::string& name) const { return findLayer(name) != nullptr; } +int BiomeRules::getLayerCount() const { return int(layers_.size()); } +std::string BiomeRules::getLayerName(int index) const { + return index >= 0 && index < int(layers_.size()) ? layers_[size_t(index)].name : std::string(); +} +int BiomeRules::getLayerPriority(const std::string& name) const { + const auto* layer = findLayer(name); + return layer ? layer->priority : 0; +} +float BiomeRules::getLayerDensity(const std::string& name) const { + const auto* layer = findLayer(name); + return layer ? layer->density : 0.f; +} + +bool BiomeRules::addAsset(const std::string& layerName, const std::string& asset, float weight, + float minScale, float maxScale, bool randomYaw) { + auto* layer = findLayer(layerName); + if (!layer || asset.empty() || weight <= 0.f || minScale <= 0.f || maxScale <= 0.f) return false; + if (minScale > maxScale) std::swap(minScale, maxScale); + layer->assets.push_back({asset, weight, minScale, maxScale, randomYaw}); + return true; +} + +int BiomeRules::getAssetCount(const std::string& layerName) const { + const auto* layer = findLayer(layerName); + return layer ? int(layer->assets.size()) : 0; +} +std::string BiomeRules::getAssetName(const std::string& layerName, int index) const { + const auto* layer = findLayer(layerName); + return layer && index >= 0 && index < int(layer->assets.size()) + ? layer->assets[size_t(index)].asset + : std::string(); +} + +bool BiomeRules::addExclusion(SpatialData* spatial) { + if (!spatial) return false; + exclusions_.push_back(std::make_shared(*spatial)); + return true; +} +int BiomeRules::getExclusionCount() const { return int(exclusions_.size()); } + +PointSet* BiomeRules::generate(SpatialData* domain, float spacing, uint32_t seed, float jitter) { + error_.clear(); + lastCandidateCount_ = 0; + lastOutputCount_ = 0; + if (!domain || spacing <= 0.f) { + error_ = "generate: requires domain and positive spacing"; + return nullptr; + } + if (layers_.empty()) { + error_ = "generate: no biome layers"; + return nullptr; + } + PointSet candidates = domain->sample(spacing, seed, jitter); + lastCandidateCount_ = candidates.getCount(); + PointSet output; + for (const auto& candidate : candidates.points()) { + bool excluded = false; + for (const auto& exclusion : exclusions_) { + if (exclusion->contains(candidate.x, candidate.y, candidate.z)) { + excluded = true; + break; + } + } + if (excluded) continue; + + const BiomeLayerRule* selected = nullptr; + for (const auto& layer : layers_) { + if (!layer.spatial->contains(candidate.x, candidate.y, candidate.z)) continue; + if (!selected || layer.priority > selected->priority) selected = &layer; + } + if (!selected || selected->assets.empty()) continue; + const uint32_t densitySeed = mixBiome(seed ^ candidate.seed ^ 0x243f6a88u); + if (biomeUnit(densitySeed) >= selected->density) continue; + + float totalWeight = 0.f; + for (const auto& asset : selected->assets) totalWeight += asset.weight; + float choice = biomeUnit(densitySeed ^ 0xb7e15162u) * totalWeight; + const BiomeAssetRule* selectedAsset = &selected->assets.back(); + for (const auto& asset : selected->assets) { + choice -= asset.weight; + if (choice <= 0.f) { + selectedAsset = &asset; + break; + } + } + + ProcgenPoint point = candidate; + point.stringAttributes["biome"] = selected->name; + point.stringAttributes["asset"] = selectedAsset->asset; + point.density = selected->density; + const float scaleT = biomeUnit(densitySeed ^ 0x9e3779b9u); + const float scale = selectedAsset->minScale + + (selectedAsset->maxScale - selectedAsset->minScale) * scaleT; + point.scaleX = point.scaleY = point.scaleZ = scale; + if (selectedAsset->randomYaw) + point.yaw = biomeUnit(densitySeed ^ 0xdeadbeefu) * 360.f; + output.points().push_back(std::move(point)); + } + lastOutputCount_ = output.getCount(); + return new PointSet(std::move(output)); +} + +std::string BiomeRules::getError() const { return error_; } +std::string BiomeRules::debugReport() const { + std::ostringstream out; + out << "layers=" << layers_.size() << " exclusions=" << exclusions_.size() + << " candidates=" << lastCandidateCount_ << " output=" << lastOutputCount_; + if (!error_.empty()) out << " error=" << error_; + return out.str(); +} + +BiomeLayerRule* BiomeRules::findLayer(const std::string& name) { + const auto found = std::find_if(layers_.begin(), layers_.end(), + [&](const BiomeLayerRule& layer) { return layer.name == name; }); + return found == layers_.end() ? nullptr : &*found; +} +const BiomeLayerRule* BiomeRules::findLayer(const std::string& name) const { + const auto found = std::find_if(layers_.begin(), layers_.end(), + [&](const BiomeLayerRule& layer) { return layer.name == name; }); + return found == layers_.end() ? nullptr : &*found; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/Biome.h b/src/modules/procgen/Biome.h new file mode 100644 index 00000000..b8418c79 --- /dev/null +++ b/src/modules/procgen/Biome.h @@ -0,0 +1,80 @@ +#pragma once + +#include "procgen/PointSet.h" +#include "procgen/SpatialData.h" + +#include +#include +#include +#include + +namespace eve::procgen { + +/** @brief One weighted asset entry in a procedural biome layer. */ +struct BiomeAssetRule { + std::string asset; + float weight = 1.f; + float minScale = 1.f; + float maxScale = 1.f; + bool randomYaw = true; +}; + +/** @brief One spatially-defined biome layer and its asset table. */ +struct BiomeLayerRule { + std::string name; + int priority = 0; + float density = 1.f; + std::shared_ptr spatial; + std::vector assets; +}; + +/** + * @brief Data-driven biome distribution rules compatible with PointGraph and scene batches. + * + * Candidates are sampled from a caller-provided domain, rejected by exclusion + * domains, assigned to the highest-priority matching layer, then deterministically + * select a weighted asset and transform from their independent point seed. + */ +class BiomeRules { +public: + /** @brief Remove layers and exclusions. */ + void clear(); + /** @brief Add a named spatial layer. Names are unique. */ + bool addLayer(const std::string& name, SpatialData* spatial, int priority, float density); + bool removeLayer(const std::string& name); + bool hasLayer(const std::string& name) const; + int getLayerCount() const; + std::string getLayerName(int index) const; + int getLayerPriority(const std::string& name) const; + float getLayerDensity(const std::string& name) const; + + /** @brief Add a weighted asset rule to a layer. */ + bool addAsset(const std::string& layerName, const std::string& asset, float weight, + float minScale, float maxScale, bool randomYaw); + int getAssetCount(const std::string& layerName) const; + std::string getAssetName(const std::string& layerName, int index) const; + + /** @brief Add a copied spatial exclusion shared by every layer. */ + bool addExclusion(SpatialData* spatial); + int getExclusionCount() const; + + /** + * @brief Generate attributed biome points. + * @return Caller-owned PointSet with `biome` and `asset` string attributes. + */ + PointSet* generate(SpatialData* domain, float spacing, uint32_t seed, float jitter); + std::string getError() const; + std::string debugReport() const; + +private: + BiomeLayerRule* findLayer(const std::string& name); + const BiomeLayerRule* findLayer(const std::string& name) const; + + std::vector layers_; + std::vector> exclusions_; + std::string error_; + int lastCandidateCount_ = 0; + int lastOutputCount_ = 0; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/BiomeScript.cpp b/src/modules/procgen/BiomeScript.cpp new file mode 100644 index 00000000..af3fa2bd --- /dev/null +++ b/src/modules/procgen/BiomeScript.cpp @@ -0,0 +1,33 @@ +#include "procgen/BiomeScript.h" + +#include "procgen/Biome.h" + +#include + +#include + +namespace eve::procgen { + +void exposeBiomeRules(ssq::Table& table) { + auto biome = table.addClass( + "ProcgenBiomeRules", std::function([]() -> BiomeRules* { return nullptr; }), + true); + biome.addFunc("clear", &BiomeRules::clear); + biome.addFunc("addLayer", &BiomeRules::addLayer); + biome.addFunc("removeLayer", &BiomeRules::removeLayer); + biome.addFunc("hasLayer", &BiomeRules::hasLayer); + biome.addFunc("getLayerCount", &BiomeRules::getLayerCount); + biome.addFunc("getLayerName", &BiomeRules::getLayerName); + biome.addFunc("getLayerPriority", &BiomeRules::getLayerPriority); + biome.addFunc("getLayerDensity", &BiomeRules::getLayerDensity); + biome.addFunc("addAsset", &BiomeRules::addAsset); + biome.addFunc("getAssetCount", &BiomeRules::getAssetCount); + biome.addFunc("getAssetName", &BiomeRules::getAssetName); + biome.addFunc("addExclusion", &BiomeRules::addExclusion); + biome.addFunc("getExclusionCount", &BiomeRules::getExclusionCount); + biome.addFunc("generate", &BiomeRules::generate); + biome.addFunc("getError", &BiomeRules::getError); + biome.addFunc("debugReport", &BiomeRules::debugReport); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/BiomeScript.h b/src/modules/procgen/BiomeScript.h new file mode 100644 index 00000000..0fc3aca2 --- /dev/null +++ b/src/modules/procgen/BiomeScript.h @@ -0,0 +1,12 @@ +#pragma once + +namespace ssq { +class Table; +} + +namespace eve::procgen { + +/** @brief Register biome rules on the Procgen module table. */ +void exposeBiomeRules(ssq::Table& table); + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointCompute.cpp b/src/modules/procgen/PointCompute.cpp new file mode 100644 index 00000000..77cf4574 --- /dev/null +++ b/src/modules/procgen/PointCompute.cpp @@ -0,0 +1,284 @@ +#include "procgen/PointCompute.h" + +#include "common/Module.h" +#include "gpgpu/ComputeShader.h" +#include "gpgpu/Gpgpu.h" +#include "gpgpu/GpuBuffer.h" +#include "gpgpu/Sequence.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +constexpr int kPointFloats = 11; +constexpr int kWorkgroupSize = 64; +constexpr int kMaxTransforms = 4; + +bool forcedFailure(const char* stage) { + const char* requested = std::getenv("EVENGINE_POINT_COMPUTE_FAIL"); + return requested && std::string(requested) == stage; +} + +const char* transformKernel() { +#ifdef EVENGINE_WEBGPU + return R"( +struct Data { values: array }; +struct Push { data: array }; +@group(0) @binding(0) var points: Data; +@group(0) @binding(8) var push: Push; +fn parameter(index: u32) -> f32 { + let value = push.data[index / 4u]; + switch index % 4u { + case 0u: { return value.x; } + case 1u: { return value.y; } + case 2u: { return value.z; } + default: { return value.w; } + } +} +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x >= u32(parameter(0u))) { return; } + let base = gid.x * 11u; + for (var operation = 0u; operation < min(u32(parameter(1u)), 4u); operation++) { + let offset = 2u + operation * 7u; + let sx = parameter(offset + 3u); + let sy = parameter(offset + 4u); + let sz = parameter(offset + 5u); + let yaw = parameter(offset + 6u); + let radians = yaw * 0.017453292519943295; + let c = cos(radians); + let s = sin(radians); + let x = points.values[base] * sx; + let z = points.values[base + 2u] * sz; + points.values[base] = x * c - z * s + parameter(offset); + points.values[base + 1u] = points.values[base + 1u] * sy + parameter(offset + 1u); + points.values[base + 2u] = x * s + z * c + parameter(offset + 2u); + var nx = points.values[base + 3u] / select(1.0, sx, abs(sx) > 0.000001); + var ny = points.values[base + 4u] / select(1.0, sy, abs(sy) > 0.000001); + var nz = points.values[base + 5u] / select(1.0, sz, abs(sz) > 0.000001); + let rotated = vec3f(nx * c - nz * s, ny, nx * s + nz * c); + let normal = select(rotated, normalize(rotated), dot(rotated, rotated) > 0.0); + points.values[base + 3u] = normal.x; + points.values[base + 4u] = normal.y; + points.values[base + 5u] = normal.z; + points.values[base + 6u] += yaw; + points.values[base + 7u] *= sx; + points.values[base + 8u] *= sy; + points.values[base + 9u] *= sz; + } +} +)"; +#else + return R"(#version 450 +layout(local_size_x = 64) in; +layout(set = 0, binding = 0) buffer Data { float values[]; } points; +layout(push_constant) uniform Push { float data[32]; } push; +void main() { + uint i = gl_GlobalInvocationID.x; + if (i >= uint(push.data[0])) return; + uint base = i * 11u; + for (int operation = 0; operation < min(int(push.data[1]), 4); ++operation) { + int offset = 2 + operation * 7; + float sx = push.data[offset + 3], sy = push.data[offset + 4], sz = push.data[offset + 5]; + float yaw = push.data[offset + 6]; + float radians = yaw * 0.017453292519943295; + float c = cos(radians), s = sin(radians); + float x = points.values[base] * sx; + float z = points.values[base + 2u] * sz; + points.values[base] = x * c - z * s + push.data[offset]; + points.values[base + 1u] = points.values[base + 1u] * sy + push.data[offset + 1]; + points.values[base + 2u] = x * s + z * c + push.data[offset + 2]; + float nx = points.values[base + 3u] / (abs(sx) > 0.000001 ? sx : 1.0); + float ny = points.values[base + 4u] / (abs(sy) > 0.000001 ? sy : 1.0); + float nz = points.values[base + 5u] / (abs(sz) > 0.000001 ? sz : 1.0); + vec3 normal = vec3(nx * c - nz * s, ny, nx * s + nz * c); + float normalLength = length(normal); + if (normalLength > 0.0) normal /= normalLength; + points.values[base + 3u] = normal.x; + points.values[base + 4u] = normal.y; + points.values[base + 5u] = normal.z; + points.values[base + 6u] += yaw; + points.values[base + 7u] *= sx; + points.values[base + 8u] *= sy; + points.values[base + 9u] *= sz; + } +} +)"; +#endif +} + +} // namespace + +struct PointCompute::Impl { + std::vector packed; + std::unique_ptr storage; + std::unique_ptr staging; + std::unique_ptr shader; + std::unique_ptr sequence; + int capacityBytes = 0; + uint64_t uploadCount = 0; + uint64_t dispatchCount = 0; + uint64_t readbackCount = 0; + uint64_t bufferReuseCount = 0; + uint64_t peakBufferBytes = 0; + int lastFusedTransformCount = 0; + + void reset() { + sequence.reset(); + shader.reset(); + staging.reset(); + storage.reset(); + capacityBytes = 0; + } +}; + +PointCompute::PointCompute() : impl_(std::make_unique()) {} +PointCompute::~PointCompute() = default; + +bool PointCompute::transform(const PointSet& input, PointSet& output, float translateX, float translateY, + float translateZ, float yawDegrees, float scaleX, float scaleY, float scaleZ) { + return transformChain(input, output, {{translateX, translateY, translateZ, yawDegrees, scaleX, scaleY, scaleZ}}); +} + +bool PointCompute::transformChain(const PointSet& input, PointSet& output, const std::vector& transforms) { + error_.clear(); + if (transforms.empty() || transforms.size() > kMaxTransforms) { + error_ = "transform chain must contain one to four operations"; + return false; + } + if (input.empty()) { + output = input; + return true; + } + constexpr size_t bytesPerPoint = size_t(kPointFloats) * sizeof(float); + if (size_t(input.getCount()) > size_t(std::numeric_limits::max()) / bytesPerPoint) { + error_ = "point data exceeds GPU buffer address range"; + return false; + } + try { + if (forcedFailure("compile")) { + error_ = "forced shader compilation failure"; + return false; + } + auto* gpu = eve::ModuleManager::getInstance("Gpgpu"); + if (!gpu) gpu = eve::gpgpu::Gpgpu::create(); + if (!gpu || !gpu->isAvailable()) { + error_ = "compute device unavailable"; + return false; + } + + impl_->packed.resize(size_t(input.getCount()) * kPointFloats); + std::vector& packed = impl_->packed; + for (int index = 0; index < input.getCount(); ++index) { + const ProcgenPoint& point = input.points()[size_t(index)]; + float* values = packed.data() + size_t(index) * kPointFloats; + values[0] = point.x; + values[1] = point.y; + values[2] = point.z; + values[3] = point.normalX; + values[4] = point.normalY; + values[5] = point.normalZ; + values[6] = point.yaw; + values[7] = point.scaleX; + values[8] = point.scaleY; + values[9] = point.scaleZ; + values[10] = point.density; + } + const int byteSize = int(packed.size() * sizeof(float)); + if (!impl_->shader) impl_->shader.reset(gpu->newShader(transformKernel())); + if (!impl_->sequence) impl_->sequence.reset(gpu->newSequence()); + const bool reusedBuffers = byteSize <= impl_->capacityBytes; + if (byteSize > impl_->capacityBytes) { + if (forcedFailure("allocation")) { + error_ = "forced GPU allocation failure"; + return false; + } + int capacity = std::max(byteSize, impl_->capacityBytes + impl_->capacityBytes / 2); + impl_->storage.reset(gpu->newBuffer(capacity, "storage")); + impl_->staging.reset(gpu->newBuffer(capacity, "staging")); + impl_->capacityBytes = capacity; + impl_->peakBufferBytes = std::max(impl_->peakBufferBytes, uint64_t(capacity)); + } + if (!impl_->storage || !impl_->staging || !impl_->shader || !impl_->sequence || + !impl_->sequence->isAvailable()) { + error_ = "compute resources unavailable"; + return false; + } + impl_->shader->bindBuffer(0, impl_->storage.get()); + impl_->shader->setFloat(0, float(input.getCount())); + impl_->shader->setFloat(1, float(transforms.size())); + for (size_t index = 0; index < transforms.size(); ++index) { + const Transform& transform = transforms[index]; + const int offset = 2 + int(index) * 7; + impl_->shader->setFloat(offset, transform.x); + impl_->shader->setFloat(offset + 1, transform.y); + impl_->shader->setFloat(offset + 2, transform.z); + impl_->shader->setFloat(offset + 3, transform.scaleX); + impl_->shader->setFloat(offset + 4, transform.scaleY); + impl_->shader->setFloat(offset + 5, transform.scaleZ); + impl_->shader->setFloat(offset + 6, transform.yaw); + } + impl_->sequence->begin(); + impl_->sequence->recordUpload(impl_->storage.get(), packed.data(), uint64_t(byteSize)); + impl_->sequence->recordDispatch(impl_->shader.get(), (input.getCount() + kWorkgroupSize - 1) / kWorkgroupSize); + impl_->sequence->recordDownload(impl_->storage.get(), impl_->staging.get(), uint64_t(byteSize)); + if (forcedFailure("submit")) { + error_ = "forced GPU submission failure"; + impl_->reset(); + return false; + } + impl_->sequence->submit(); + if (forcedFailure("readback")) { + error_ = "forced GPU readback failure"; + impl_->reset(); + return false; + } + impl_->staging->downloadBytes(packed.data(), uint64_t(byteSize)); + ++impl_->uploadCount; + ++impl_->dispatchCount; + ++impl_->readbackCount; + if (reusedBuffers) ++impl_->bufferReuseCount; + impl_->lastFusedTransformCount = int(transforms.size()); + + output = input; + for (int index = 0; index < output.getCount(); ++index) { + ProcgenPoint& point = output.points()[size_t(index)]; + const float* values = packed.data() + size_t(index) * kPointFloats; + point.x = values[0]; + point.y = values[1]; + point.z = values[2]; + point.normalX = values[3]; + point.normalY = values[4]; + point.normalZ = values[5]; + point.yaw = values[6]; + point.scaleX = values[7]; + point.scaleY = values[8]; + point.scaleZ = values[9]; + point.density = values[10]; + } + return true; + } catch (const std::exception& exception) { + error_ = exception.what(); + impl_->reset(); + } catch (...) { + error_ = "unknown compute failure"; + impl_->reset(); + } + return false; +} + +uint64_t PointCompute::getUploadCount() const { return impl_->uploadCount; } +uint64_t PointCompute::getDispatchCount() const { return impl_->dispatchCount; } +uint64_t PointCompute::getReadbackCount() const { return impl_->readbackCount; } +uint64_t PointCompute::getBufferReuseCount() const { return impl_->bufferReuseCount; } +uint64_t PointCompute::getPeakBufferBytes() const { return impl_->peakBufferBytes; } +int PointCompute::getLastFusedTransformCount() const { return impl_->lastFusedTransformCount; } + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointCompute.h b/src/modules/procgen/PointCompute.h new file mode 100644 index 00000000..a0de974d --- /dev/null +++ b/src/modules/procgen/PointCompute.h @@ -0,0 +1,62 @@ +#pragma once + +#include "procgen/PointSet.h" + +#include +#include +#include +#include + +namespace eve::procgen { + +/** @brief Backend-neutral GPU kernels used by PointGraph with deterministic CPU fallback. */ +class PointCompute { +public: + /** @brief One transform operation in a fused point-compute dispatch. */ + struct Transform { + float x = 0.f, y = 0.f, z = 0.f; + float yaw = 0.f; + float scaleX = 1.f, scaleY = 1.f, scaleZ = 1.f; + }; + + PointCompute(); + ~PointCompute(); + PointCompute(const PointCompute&) = delete; + PointCompute& operator=(const PointCompute&) = delete; + + /** + * @brief Transform attributed points through the active Vulkan or WebGPU compute backend. + * @return True when GPU execution completed; false leaves output untouched and describes + * the fallback reason in getError(). + */ + bool transform(const PointSet& input, PointSet& output, float translateX, float translateY, float translateZ, + float yawDegrees, float scaleX, float scaleY, float scaleZ); + + /** + * @brief Execute one to four transforms in one upload, dispatch and readback. + * @return True when GPU execution completed; false leaves output untouched. + */ + bool transformChain(const PointSet& input, PointSet& output, const std::vector& transforms); + + /** @brief Return the last GPU unavailability, compilation, dispatch, or readback error. */ + const std::string& getError() const { return error_; } + /** @brief Return successful host-to-GPU upload count for this executor. */ + uint64_t getUploadCount() const; + /** @brief Return successful compute dispatch count for this executor. */ + uint64_t getDispatchCount() const; + /** @brief Return successful GPU-to-host readback count for this executor. */ + uint64_t getReadbackCount() const; + /** @brief Return executions that reused existing GPU buffers. */ + uint64_t getBufferReuseCount() const; + /** @brief Return peak bytes reserved in each reusable GPU point buffer. */ + uint64_t getPeakBufferBytes() const; + /** @brief Return logical transform count in the most recent successful dispatch. */ + int getLastFusedTransformCount() const; + +private: + struct Impl; + std::unique_ptr impl_; + std::string error_; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointGraph.cpp b/src/modules/procgen/PointGraph.cpp new file mode 100644 index 00000000..4065a8fd --- /dev/null +++ b/src/modules/procgen/PointGraph.cpp @@ -0,0 +1,974 @@ +#include "procgen/PointGraph.h" + +#include "procgen/Biome.h" +#include "procgen/PointCompute.h" +#include "procgen/ShapeGrammar.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +struct OperationParam { + const char* key; + const char* kind; + const char* defaultValue; +}; +struct OperationSpec { + const char* id; + int inputs; + std::vector params; +}; + +const std::vector& operationSpecs() { + static const std::vector value = { + {"input", 0, {}}, + {"spatial.sample", 0, {{"spacing", "float", "1"}, {"seed", "int", "1"}, {"jitter", "float", "0"}}}, + {"spatial.filter", 1, {{"invert", "bool", "false"}}}, + {"spatial.project", 1, {}}, + {"biome.generate", 0, {{"spacing", "float", "1"}, {"seed", "int", "1"}, + {"jitter", "float", "0"}}}, + {"grammar.generate", 1, {{"grammar", "string", ""}, {"seed", "int", "1"}, + {"acceptIncomplete", "bool", "true"}}}, + {"merge", 2, {}}, + {"copy.points", 2, {{"maxPoints", "int", "100000"}, + {"inheritTargetAttributes", "bool", "true"}}}, + {"density.remap", 1, {{"inputMin", "float", "0"}, {"inputMax", "float", "1"}, + {"outputMin", "float", "0"}, {"outputMax", "float", "1"}, + {"clamp", "bool", "true"}}}, + {"attribute.math.float", 1, {{"attribute", "string", ""}, + {"outputAttribute", "string", ""}, + {"operation", "string", "multiply"}, + {"operand", "float", "1"}, + {"defaultValue", "float", "0"}}}, + {"transform", 1, {{"x", "float", "0"}, {"y", "float", "0"}, {"z", "float", "0"}, + {"yaw", "float", "0"}, {"scaleX", "float", "1"}, + {"scaleY", "float", "1"}, {"scaleZ", "float", "1"}}}, + {"filter.float", 1, {{"attribute", "string", ""}, {"min", "float", "0"}, + {"max", "float", "1"}, {"invert", "bool", "false"}}}, + {"filter.string", 1, {{"attribute", "string", ""}, {"value", "string", ""}, + {"invert", "bool", "false"}}}, + {"attribute.set.float", 1, {{"attribute", "string", ""}, {"value", "float", "0"}}}, + {"attribute.set.string", 1, {{"attribute", "string", ""}, {"value", "string", ""}}}, + {"density.cull", 1, {{"seed", "int", "1"}, {"multiplier", "float", "1"}}}, + {"self.prune", 1, {{"radius", "float", "1"}}}, + {"jitter", 1, {{"seed", "int", "1"}, {"x", "float", "0"}, {"z", "float", "0"}}}, + {"branch", 2, {{"condition", "bool", "false"}}}, + {"subgraph", 1, {}}, + }; + return value; +} + +bool operationKnown(const std::string& operation) { + const auto& values = operationSpecs(); + return std::any_of(values.begin(), values.end(), + [&](const OperationSpec& value) { return operation == value.id; }); +} + +const OperationSpec* operationSpec(const std::string& operation) { + const auto& values = operationSpecs(); + const auto found = std::find_if(values.begin(), values.end(), + [&](const OperationSpec& value) { return operation == value.id; }); + return found == values.end() ? nullptr : &*found; +} + +const OperationParam* operationParam(const OperationSpec* spec, const std::string& key) { + if (!spec) return nullptr; + const auto found = std::find_if(spec->params.begin(), spec->params.end(), + [&](const OperationParam& value) { return key == value.key; }); + return found == spec->params.end() ? nullptr : &*found; +} + +uint64_t nowNanoseconds() { + return uint64_t(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +PointGraphNodeMetric makeMetric(const std::string& id, const PointSet& points, float milliseconds, bool cacheHit, + const std::string& backend = "cpu") { + PointGraphNodeMetric metric; + metric.id = id; + metric.outputCount = points.getCount(); + metric.milliseconds = milliseconds; + metric.cacheHit = cacheHit; + metric.backend = backend; + if (points.empty()) return metric; + const auto& first = points.points().front(); + metric.minX = metric.maxX = first.x; + metric.minY = metric.maxY = first.y; + metric.minZ = metric.maxZ = first.z; + double densitySum = 0.0; + for (const auto& point : points.points()) { + metric.minX = std::min(metric.minX, point.x); + metric.minY = std::min(metric.minY, point.y); + metric.minZ = std::min(metric.minZ, point.z); + metric.maxX = std::max(metric.maxX, point.x); + metric.maxY = std::max(metric.maxY, point.y); + metric.maxZ = std::max(metric.maxZ, point.z); + densitySum += point.density; + } + metric.averageDensity = float(densitySum / double(points.getCount())); + return metric; +} + +uint64_t spatialSampleUpperBound(const SpatialData& spatial, float spacing) { + if (!spatial.hasBounds() || spacing <= 0.f) return 0; + const auto axisCount = [spacing](float minimum, float maximum) { + if (maximum <= minimum) return uint64_t(1); + return uint64_t(std::floor(double(maximum - minimum) / double(spacing) + 0.001)) + 1; + }; + const uint64_t x = axisCount(spatial.getMinX(), spatial.getMaxX()); + const uint64_t y = spatial.getKind() == "surface.heightfield" + ? uint64_t(1) + : axisCount(spatial.getMinY(), spatial.getMaxY()); + const uint64_t z = axisCount(spatial.getMinZ(), spatial.getMaxZ()); + const uint64_t maximum = std::numeric_limits::max(); + if (x > maximum / y) return maximum; + const uint64_t xy = x * y; + return xy > maximum / z ? maximum : xy * z; +} + +} // namespace + +PointGraph::PointGraph() : pointCompute_(std::make_shared()) {} + +bool PointGraph::addNode(const std::string& id, const std::string& operation) { + if (id.empty() || !operationKnown(operation) || nodes_.find(id) != nodes_.end()) return false; + nodes_[id].id = id; + nodes_[id].operation = operation; + nodeOrder_.push_back(id); + invalidateFrom(id); + return true; +} + +bool PointGraph::removeNode(const std::string& id) { + if (nodes_.find(id) == nodes_.end()) return false; + invalidateFrom(id); + nodes_.erase(id); + nodeOrder_.erase(std::remove(nodeOrder_.begin(), nodeOrder_.end(), id), nodeOrder_.end()); + for (auto it = parameterOrder_.begin(); it != parameterOrder_.end();) { + const auto binding = parameters_.find(*it); + if (binding != parameters_.end() && binding->second.nodeId == id) { + floatOverrides_.erase(*it); + intOverrides_.erase(*it); + stringOverrides_.erase(*it); + parameters_.erase(binding); + it = parameterOrder_.erase(it); + } else { + ++it; + } + } + for (auto& [otherId, node] : nodes_) + for (auto& input : node.inputs) + if (input == id) input.clear(); + return true; +} + +bool PointGraph::hasNode(const std::string& id) const { return nodes_.find(id) != nodes_.end(); } +int PointGraph::getNodeCount() const { return int(nodeOrder_.size()); } +std::string PointGraph::getNodeId(int index) const { + return index >= 0 && index < int(nodeOrder_.size()) ? nodeOrder_[size_t(index)] : std::string(); +} +std::string PointGraph::getNodeOperation(const std::string& id) const { + const auto found = nodes_.find(id); + return found == nodes_.end() ? std::string() : found->second.operation; +} + +bool PointGraph::connect(const std::string& fromId, const std::string& toId, int inputIndex) { + const auto from = nodes_.find(fromId); + const auto to = nodes_.find(toId); + const auto* spec = to == nodes_.end() ? nullptr : operationSpec(to->second.operation); + if (from == nodes_.end() || to == nodes_.end() || !spec || inputIndex < 0 || + inputIndex >= spec->inputs || fromId == toId) + return false; + to->second.inputs[inputIndex] = fromId; + invalidateFrom(toId); + return true; +} + +bool PointGraph::disconnect(const std::string& toId, int inputIndex) { + const auto to = nodes_.find(toId); + if (to == nodes_.end() || inputIndex < 0 || inputIndex > 1 || + to->second.inputs[inputIndex].empty()) + return false; + to->second.inputs[inputIndex].clear(); + invalidateFrom(toId); + return true; +} + +std::string PointGraph::getInputNode(const std::string& nodeId, int inputIndex) const { + const auto found = nodes_.find(nodeId); + return found != nodes_.end() && inputIndex >= 0 && inputIndex <= 1 + ? found->second.inputs[inputIndex] + : std::string(); +} + +bool PointGraph::setNodePoints(const std::string& id, PointSet* points) { + const auto found = nodes_.find(id); + if (found == nodes_.end() || !points) return false; + if (maxNodeOutputPoints_ > 0 && points->getCount() > maxNodeOutputPoints_) { + error_ = "input exceeds node point budget at node: " + id; + return false; + } + found->second.points = *points; + found->second.hasPoints = true; + invalidateFrom(id); + return true; +} + +bool PointGraph::setNodeSpatial(const std::string& id, SpatialData* spatial) { + const auto found = nodes_.find(id); + if (found == nodes_.end() || !spatial) return false; + found->second.spatial = std::make_shared(*spatial); + invalidateFrom(id); + return true; +} + +bool PointGraph::setNodeBiomeRules(const std::string& id, BiomeRules* rules) { + const auto found = nodes_.find(id); + if (found == nodes_.end() || found->second.operation != "biome.generate" || !rules) + return false; + found->second.biomeRules = std::make_shared(*rules); + invalidateFrom(id); + return true; +} + +bool PointGraph::setNodeShapeGrammar(const std::string& id, ShapeGrammar* grammar) { + const auto found = nodes_.find(id); + if (found == nodes_.end() || found->second.operation != "grammar.generate" || !grammar) + return false; + found->second.shapeGrammar = std::make_shared(*grammar); + invalidateFrom(id); + return true; +} + +bool PointGraph::setNodeFloat(const std::string& id, const std::string& key, float value) { + const auto found = nodes_.find(id); + const auto* spec = found == nodes_.end() ? nullptr : operationSpec(found->second.operation); + const auto* parameter = operationParam(spec, key); + if (!parameter || std::string(parameter->kind) != "float") return false; + found->second.floats[key] = value; + invalidateFrom(id); + return true; +} + +bool PointGraph::setNodeInt(const std::string& id, const std::string& key, int value) { + const auto found = nodes_.find(id); + const auto* spec = found == nodes_.end() ? nullptr : operationSpec(found->second.operation); + const auto* parameter = operationParam(spec, key); + if (!parameter || + (std::string(parameter->kind) != "int" && std::string(parameter->kind) != "bool")) + return false; + found->second.ints[key] = value; + invalidateFrom(id); + return true; +} + +bool PointGraph::setNodeString(const std::string& id, const std::string& key, + const std::string& value) { + const auto found = nodes_.find(id); + const auto* spec = found == nodes_.end() ? nullptr : operationSpec(found->second.operation); + const auto* parameter = operationParam(spec, key); + if (!parameter || std::string(parameter->kind) != "string") return false; + found->second.strings[key] = value; + invalidateFrom(id); + return true; +} + +bool PointGraph::exposeParameter(const std::string& name, const std::string& nodeId, + const std::string& key) { + const auto node = nodes_.find(nodeId); + if (name.empty() || key.empty() || node == nodes_.end() || parameters_.count(name) != 0) + return false; + const OperationSpec* spec = operationSpec(node->second.operation); + if (!spec) return false; + const auto* parameter = operationParam(spec, key); + if (!parameter) return false; + for (const auto& [existingName, binding] : parameters_) + if (binding.nodeId == nodeId && binding.key == key) return false; + parameters_.emplace(name, ParameterBinding{nodeId, key, parameter->kind}); + parameterOrder_.push_back(name); + ++revision_; + return true; +} + +int PointGraph::getParameterCount() const { return int(parameterOrder_.size()); } +std::string PointGraph::getParameterName(int index) const { + return index >= 0 && index < int(parameterOrder_.size()) ? parameterOrder_[size_t(index)] + : std::string(); +} +std::string PointGraph::getParameterKind(const std::string& name) const { + const auto found = parameters_.find(name); + return found == parameters_.end() ? std::string() : found->second.kind; +} +bool PointGraph::hasParameterOverride(const std::string& name) const { + return floatOverrides_.count(name) != 0 || intOverrides_.count(name) != 0 || + stringOverrides_.count(name) != 0; +} +bool PointGraph::setParameterFloat(const std::string& name, float value) { + const auto found = parameters_.find(name); + if (found == parameters_.end() || found->second.kind != "float") return false; + floatOverrides_[name] = value; + invalidateFrom(found->second.nodeId); + return true; +} +bool PointGraph::setParameterInt(const std::string& name, int value) { + const auto found = parameters_.find(name); + if (found == parameters_.end() || (found->second.kind != "int" && + found->second.kind != "bool")) + return false; + intOverrides_[name] = value; + invalidateFrom(found->second.nodeId); + return true; +} +bool PointGraph::setParameterString(const std::string& name, const std::string& value) { + const auto found = parameters_.find(name); + if (found == parameters_.end() || found->second.kind != "string") return false; + stringOverrides_[name] = value; + invalidateFrom(found->second.nodeId); + return true; +} +float PointGraph::getParameterFloat(const std::string& name, float fallback) const { + const auto found = parameters_.find(name); + if (found == parameters_.end() || found->second.kind != "float") return fallback; + return floatValue(nodes_.at(found->second.nodeId), found->second.key, fallback); +} +int PointGraph::getParameterInt(const std::string& name, int fallback) const { + const auto found = parameters_.find(name); + if (found == parameters_.end() || (found->second.kind != "int" && + found->second.kind != "bool")) + return fallback; + return intValue(nodes_.at(found->second.nodeId), found->second.key, fallback); +} +std::string PointGraph::getParameterString(const std::string& name, + const std::string& fallback) const { + const auto found = parameters_.find(name); + if (found == parameters_.end() || found->second.kind != "string") return fallback; + return stringValue(nodes_.at(found->second.nodeId), found->second.key, fallback); +} +bool PointGraph::clearParameterOverride(const std::string& name) { + const auto found = parameters_.find(name); + if (found == parameters_.end() || !hasParameterOverride(name)) return false; + floatOverrides_.erase(name); + intOverrides_.erase(name); + stringOverrides_.erase(name); + invalidateFrom(found->second.nodeId); + return true; +} + +bool PointGraph::setNodeSubgraph(const std::string& id, PointGraph* graph, + const std::string& inputNode, const std::string& outputNode) { + const auto found = nodes_.find(id); + if (found == nodes_.end() || found->second.operation != "subgraph" || !graph || + !graph->hasNode(inputNode) || !graph->hasNode(outputNode)) + return false; + found->second.subgraph = std::make_shared(*graph); + found->second.subgraphInput = inputNode; + found->second.subgraphOutput = outputNode; + invalidateFrom(id); + return true; +} + +PointSet* PointGraph::execute(const std::string& outputId) { + error_.clear(); + metrics_.clear(); + cacheHitCount_ = 0; + evaluatedNodes_ = 0; + lastCancelled_ = false; + computeFallbackReason_.clear(); + ++executionCount_; + if (cancelRequested_) { + error_ = "execution cancelled"; + lastCancelled_ = true; + return nullptr; + } + std::unordered_map states; + const PointSet* result = evaluate(outputId, states); + return result ? new PointSet(*result) : nullptr; +} + +void PointGraph::setExecutionNodeBudget(int nodes) { executionNodeBudget_ = std::max(0, nodes); } +int PointGraph::getExecutionNodeBudget() const { return executionNodeBudget_; } +void PointGraph::setMaxNodeOutputPoints(int points) { + const int clamped = std::max(0, points); + if (maxNodeOutputPoints_ == clamped) return; + maxNodeOutputPoints_ = clamped; + clearCache(); +} +int PointGraph::getMaxNodeOutputPoints() const { return maxNodeOutputPoints_; } +bool PointGraph::setComputePolicy(const std::string& policy) { + if (policy != "auto" && policy != "gpu" && policy != "cpu") return false; + if (computePolicy_ == policy) return true; + computePolicy_ = policy; + clearCache(); + return true; +} +std::string PointGraph::getComputePolicy() const { return computePolicy_; } +void PointGraph::setComputeMinimumPoints(int points) { + const int clamped = std::max(0, points); + if (computeMinimumPoints_ == clamped) return; + computeMinimumPoints_ = clamped; + clearCache(); +} +int PointGraph::getComputeMinimumPoints() const { return computeMinimumPoints_; } +void PointGraph::requestCancel() { cancelRequested_ = true; } +void PointGraph::resetCancellation() { + cancelRequested_ = false; + lastCancelled_ = false; +} +bool PointGraph::wasCancelled() const { return lastCancelled_; } + +bool PointGraph::validate() { + error_.clear(); + std::unordered_map states; + for (const auto& id : nodeOrder_) + if (!validateNode(id, states)) return false; + return true; +} + +std::string PointGraph::getError() const { return error_; } +void PointGraph::clearCache() { + for (auto& [id, node] : nodes_) { + node.cacheValid = false; + node.deferredTransformValid = false; + } + metrics_.clear(); +} +int PointGraph::getExecutionCount() const { return executionCount_; } +int PointGraph::getCacheHitCount() const { return cacheHitCount_; } +uint64_t PointGraph::getRevision() const { return revision_; } +int PointGraph::getMetricCount() const { return int(metrics_.size()); } +std::string PointGraph::getMetricNodeId(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].id : std::string(); +} +int PointGraph::getMetricOutputCount(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].outputCount : 0; +} +float PointGraph::getMetricMilliseconds(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].milliseconds : 0.f; +} +bool PointGraph::isMetricCacheHit(int index) const { + return index >= 0 && index < int(metrics_.size()) && metrics_[size_t(index)].cacheHit; +} +std::string PointGraph::getMetricBackend(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].backend : std::string(); +} +std::string PointGraph::getComputeFallbackReason() const { return computeFallbackReason_; } +uint64_t PointGraph::getComputeUploadCount() const { return pointCompute_->getUploadCount(); } +uint64_t PointGraph::getComputeDispatchCount() const { return pointCompute_->getDispatchCount(); } +uint64_t PointGraph::getComputeReadbackCount() const { return pointCompute_->getReadbackCount(); } +uint64_t PointGraph::getComputeBufferReuseCount() const { return pointCompute_->getBufferReuseCount(); } +uint64_t PointGraph::getComputePeakBufferBytes() const { return pointCompute_->getPeakBufferBytes(); } +int PointGraph::getLastFusedTransformCount() const { return pointCompute_->getLastFusedTransformCount(); } +float PointGraph::getMetricMinX(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].minX : 0.f; +} +float PointGraph::getMetricMinY(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].minY : 0.f; +} +float PointGraph::getMetricMinZ(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].minZ : 0.f; +} +float PointGraph::getMetricMaxX(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].maxX : 0.f; +} +float PointGraph::getMetricMaxY(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].maxY : 0.f; +} +float PointGraph::getMetricMaxZ(int index) const { + return index >= 0 && index < int(metrics_.size()) ? metrics_[size_t(index)].maxZ : 0.f; +} +float PointGraph::getMetricAverageDensity(int index) const { + return index >= 0 && index < int(metrics_.size()) + ? metrics_[size_t(index)].averageDensity + : 0.f; +} +PointSet* PointGraph::getNodeOutput(const std::string& id) const { return materializeNodeOutput(id); } + +PointSet* PointGraph::materializeNodeOutput(const std::string& id) const { + const auto found = nodes_.find(id); + if (found == nodes_.end()) return nullptr; + const Node& node = found->second; + if (node.cacheValid) return new PointSet(node.cache); + if (!node.deferredTransformValid || node.operation != "transform" || node.inputs[0].empty()) return nullptr; + std::unique_ptr input(materializeNodeOutput(node.inputs[0])); + if (!input) return nullptr; + return new PointSet(transformPointSet(*input, floatValue(node, "x", 0.f), floatValue(node, "y", 0.f), + floatValue(node, "z", 0.f), floatValue(node, "yaw", 0.f), + floatValue(node, "scaleX", 1.f), floatValue(node, "scaleY", 1.f), + floatValue(node, "scaleZ", 1.f))); +} + +std::string PointGraph::debugReport() const { + std::ostringstream out; + out << "nodes=" << nodes_.size() << " executions=" << executionCount_ + << " cacheHits=" << cacheHitCount_; + for (const auto& metric : metrics_) + out << "\n" + << metric.id << " count=" << metric.outputCount << " ms=" << metric.milliseconds + << " backend=" << metric.backend << (metric.cacheHit ? " cached" : ""); + if (!error_.empty()) out << "\nerror=" << error_; + return out.str(); +} + +int PointGraph::getOperationCount() { return int(operationSpecs().size()); } +std::string PointGraph::getOperationId(int index) { + return index >= 0 && index < int(operationSpecs().size()) + ? operationSpecs()[size_t(index)].id + : std::string(); +} +int PointGraph::getOperationInputCount(const std::string& operation) { + const auto* spec = operationSpec(operation); + return spec ? spec->inputs : -1; +} +int PointGraph::getOperationParamCount(const std::string& operation) { + const auto* spec = operationSpec(operation); + return spec ? int(spec->params.size()) : 0; +} +std::string PointGraph::getOperationParamKey(const std::string& operation, int index) { + const auto* spec = operationSpec(operation); + return spec && index >= 0 && index < int(spec->params.size()) + ? spec->params[size_t(index)].key + : std::string(); +} +std::string PointGraph::getOperationParamKind(const std::string& operation, int index) { + const auto* spec = operationSpec(operation); + return spec && index >= 0 && index < int(spec->params.size()) + ? spec->params[size_t(index)].kind + : std::string(); +} +std::string PointGraph::getOperationParamDefault(const std::string& operation, int index) { + const auto* spec = operationSpec(operation); + return spec && index >= 0 && index < int(spec->params.size()) + ? spec->params[size_t(index)].defaultValue + : std::string(); +} + +const PointSet* PointGraph::evaluate(const std::string& id, + std::unordered_map& states) { + const auto found = nodes_.find(id); + if (found == nodes_.end()) { + error_ = "unknown node: " + id; + return nullptr; + } + Node& node = found->second; + if (node.cacheValid) { + ++cacheHitCount_; + PointGraphNodeMetric metric = node.cacheMetric; + metric.milliseconds = 0.f; + metric.cacheHit = true; + metrics_.push_back(std::move(metric)); + return &node.cache; + } + if (states[id] == 1) { + error_ = "cycle at node: " + id; + return nullptr; + } + if (cancelRequested_) { + error_ = "execution cancelled at node: " + id; + lastCancelled_ = true; + return nullptr; + } + if (node.operation == "transform") { + const PointSet* segment = evaluateTransformSegment(id, states); + if (segment || !error_.empty()) return segment; + } + states[id] = 1; + const uint64_t started = nowNanoseconds(); + std::string nodeBackend = "cpu"; + const PointSet* first = nullptr; + const PointSet* second = nullptr; + if (!node.inputs[0].empty()) { + first = evaluate(node.inputs[0], states); + if (!first) return nullptr; + } + if (!node.inputs[1].empty()) { + second = evaluate(node.inputs[1], states); + if (!second) return nullptr; + } + if (executionNodeBudget_ > 0 && evaluatedNodes_ >= executionNodeBudget_) { + error_ = "execution node budget exceeded at node: " + id; + lastCancelled_ = true; + return nullptr; + } + ++evaluatedNodes_; + + if (node.operation == "input") { + if (!node.hasPoints) error_ = "input node has no points: " + id; + else node.cache = node.points; + } else if (node.operation == "spatial.sample") { + const float spacing = floatValue(node, "spacing", 1.f); + if (!node.spatial) error_ = "spatial.sample has no spatial data: " + id; + else if (maxNodeOutputPoints_ > 0 && + spatialSampleUpperBound(*node.spatial, spacing) > uint64_t(maxNodeOutputPoints_)) + error_ = "spatial.sample exceeds node point budget at node: " + id; + else + node.cache = node.spatial->sample(spacing, uint32_t(intValue(node, "seed", 1)), + floatValue(node, "jitter", 0.f)); + } else if (node.operation == "spatial.filter") { + if (!first || !node.spatial) error_ = "spatial.filter requires input and spatial data: " + id; + else node.cache = node.spatial->filter(*first, intValue(node, "invert", 0) != 0); + } else if (node.operation == "spatial.project") { + if (!first || !node.spatial) error_ = "spatial.project requires input and spatial data: " + id; + else node.cache = node.spatial->project(*first); + } else if (node.operation == "biome.generate") { + const float spacing = floatValue(node, "spacing", 1.f); + if (!node.spatial || !node.biomeRules) + error_ = "biome.generate requires spatial data and biome rules: " + id; + else if (maxNodeOutputPoints_ > 0 && + spatialSampleUpperBound(*node.spatial, spacing) > uint64_t(maxNodeOutputPoints_)) + error_ = "biome.generate exceeds node point budget at node: " + id; + else { + std::unique_ptr result(node.biomeRules->generate( + node.spatial.get(), spacing, uint32_t(intValue(node, "seed", 1)), + floatValue(node, "jitter", 0.f))); + if (!result) + error_ = "biome.generate failed at " + id + ": " + node.biomeRules->getError(); + else + node.cache = *result; + } + } else if (node.operation == "grammar.generate") { + if (!first || !node.shapeGrammar) + error_ = "grammar.generate requires input and shape grammar: " + id; + else { + std::unique_ptr result(node.shapeGrammar->generate( + stringValue(node, "grammar"), const_cast(first), + uint32_t(intValue(node, "seed", 1)), + intValue(node, "acceptIncomplete", 1) != 0)); + if (!result) + error_ = "grammar.generate failed at " + id + ": " + + node.shapeGrammar->getError(); + else + node.cache = *result; + } + } else if (node.operation == "merge") { + const uint64_t outputCount = first && second + ? uint64_t(first->getCount()) + uint64_t(second->getCount()) + : 0; + if (!first || !second) error_ = "merge requires two inputs: " + id; + else if (maxNodeOutputPoints_ > 0 && outputCount > uint64_t(maxNodeOutputPoints_)) + error_ = "merge exceeds node point budget at node: " + id; + else node.cache = mergePointSets(*first, *second); + } else if (node.operation == "copy.points") { + const int maxPoints = intValue(node, "maxPoints", 100000); + const uint64_t outputCount = first && second + ? uint64_t(first->getCount()) * uint64_t(second->getCount()) + : 0; + if (!first || !second) error_ = "copy.points requires source and target inputs: " + id; + else if (maxPoints < 0 || outputCount > uint64_t(maxPoints)) + error_ = "copy.points output exceeds maxPoints at node: " + id; + else if (maxNodeOutputPoints_ > 0 && outputCount > uint64_t(maxNodeOutputPoints_)) + error_ = "copy.points exceeds node point budget at node: " + id; + else + node.cache = copyPointsToTargets( + *first, *second, intValue(node, "inheritTargetAttributes", 1) != 0); + } else if (node.operation == "density.remap") { + const float inputMin = floatValue(node, "inputMin", 0.f); + const float inputMax = floatValue(node, "inputMax", 1.f); + if (!first) error_ = "density.remap requires input: " + id; + else if (inputMin == inputMax) + error_ = "density.remap requires a non-zero input range: " + id; + else + node.cache = remapPointDensity( + *first, inputMin, inputMax, floatValue(node, "outputMin", 0.f), + floatValue(node, "outputMax", 1.f), intValue(node, "clamp", 1) != 0); + } else if (node.operation == "attribute.math.float") { + const std::string attribute = stringValue(node, "attribute"); + std::string outputAttribute = stringValue(node, "outputAttribute"); + if (outputAttribute.empty()) outputAttribute = attribute; + const std::string operation = stringValue(node, "operation", "multiply"); + const float operand = floatValue(node, "operand", 1.f); + const bool operationKnown = operation == "add" || operation == "subtract" || + operation == "multiply" || operation == "divide" || + operation == "min" || operation == "max"; + if (!first) error_ = "attribute.math.float requires input: " + id; + else if (attribute.empty()) error_ = "attribute.math.float requires an attribute: " + id; + else if (!operationKnown) + error_ = "attribute.math.float has invalid operation at node: " + id; + else if (operation == "divide" && operand == 0.f) + error_ = "attribute.math.float cannot divide by zero at node: " + id; + else + node.cache = mathPointFloatAttribute( + *first, attribute, outputAttribute, operation, operand, + floatValue(node, "defaultValue", 0.f)); + } else if (node.operation == "transform") { + if (!first) error_ = "transform requires input: " + id; + else { + const bool gpuEligible = + !first->empty() && + (computePolicy_ == "gpu" || (computePolicy_ == "auto" && first->getCount() >= computeMinimumPoints_)); + const bool gpuComplete = + gpuEligible && + pointCompute_->transform(*first, node.cache, floatValue(node, "x", 0.f), floatValue(node, "y", 0.f), + floatValue(node, "z", 0.f), floatValue(node, "yaw", 0.f), + floatValue(node, "scaleX", 1.f), floatValue(node, "scaleY", 1.f), + floatValue(node, "scaleZ", 1.f)); + if (gpuComplete) { +#ifdef EVENGINE_WEBGPU + nodeBackend = "webgpu"; +#else + nodeBackend = "vulkan"; +#endif + } else { + if (gpuEligible) computeFallbackReason_ = pointCompute_->getError(); + node.cache = transformPointSet(*first, floatValue(node, "x", 0.f), floatValue(node, "y", 0.f), + floatValue(node, "z", 0.f), floatValue(node, "yaw", 0.f), + floatValue(node, "scaleX", 1.f), floatValue(node, "scaleY", 1.f), + floatValue(node, "scaleZ", 1.f)); + } + } + } else if (node.operation == "filter.float") { + if (!first) error_ = "filter.float requires input: " + id; + else + node.cache = filterPointFloatAttribute( + *first, stringValue(node, "attribute"), floatValue(node, "min", 0.f), + floatValue(node, "max", 1.f), intValue(node, "invert", 0) != 0); + } else if (node.operation == "filter.string") { + if (!first) error_ = "filter.string requires input: " + id; + else + node.cache = filterPointStringAttribute( + *first, stringValue(node, "attribute"), stringValue(node, "value"), + intValue(node, "invert", 0) != 0); + } else if (node.operation == "attribute.set.float") { + if (!first) error_ = "attribute.set.float requires input: " + id; + else { + node.cache = *first; + const std::string attribute = stringValue(node, "attribute"); + if (attribute.empty()) error_ = "attribute.set.float requires attribute: " + id; + else + for (int i = 0; i < node.cache.getCount(); ++i) + node.cache.setFloatAttribute(i, attribute, floatValue(node, "value", 0.f)); + } + } else if (node.operation == "attribute.set.string") { + if (!first) error_ = "attribute.set.string requires input: " + id; + else { + node.cache = *first; + const std::string attribute = stringValue(node, "attribute"); + if (attribute.empty()) error_ = "attribute.set.string requires attribute: " + id; + else + for (int i = 0; i < node.cache.getCount(); ++i) + node.cache.setStringAttribute(i, attribute, stringValue(node, "value")); + } + } else if (node.operation == "density.cull") { + if (!first) error_ = "density.cull requires input: " + id; + else + node.cache = densityCullPoints(*first, uint32_t(intValue(node, "seed", 1)), + floatValue(node, "multiplier", 1.f)); + } else if (node.operation == "self.prune") { + if (!first) error_ = "self.prune requires input: " + id; + else node.cache = selfPrunePoints(*first, floatValue(node, "radius", 1.f)); + } else if (node.operation == "jitter") { + if (!first) error_ = "jitter requires input: " + id; + else + node.cache = jitterPointPositions(*first, uint32_t(intValue(node, "seed", 1)), + floatValue(node, "x", 0.f), + floatValue(node, "z", 0.f)); + } else if (node.operation == "branch") { + const bool condition = intValue(node, "condition", 0) != 0; + if ((condition && !first) || (!condition && !second)) + error_ = "branch requires both selected inputs: " + id; + else node.cache = condition ? *first : *second; + } else if (node.operation == "subgraph") { + if (!first || !node.subgraph) error_ = "subgraph requires input and graph: " + id; + else { + node.subgraph->setMaxNodeOutputPoints(maxNodeOutputPoints_); + node.subgraph->setComputePolicy(computePolicy_); + node.subgraph->setComputeMinimumPoints(computeMinimumPoints_); + if (!node.subgraph->setNodePoints(node.subgraphInput, const_cast(first))) + error_ = "subgraph input failed at " + id + ": " + node.subgraph->getError(); + else { + std::unique_ptr result(node.subgraph->execute(node.subgraphOutput)); + if (!result) + error_ = "subgraph failed at " + id + ": " + node.subgraph->getError(); + else + node.cache = *result; + } + } + } + + if (error_.empty() && maxNodeOutputPoints_ > 0 && + node.cache.getCount() > maxNodeOutputPoints_) { + node.cache.clear(); + error_ = node.operation + " exceeds node point budget at node: " + id; + } + if (!error_.empty()) return nullptr; + node.cacheValid = true; + states[id] = 2; + const float elapsed = float(nowNanoseconds() - started) * 0.000001f; + node.cacheMetric = makeMetric(id, node.cache, elapsed, false, nodeBackend); + metrics_.push_back(node.cacheMetric); + return &node.cache; +} + +const PointSet* PointGraph::evaluateTransformSegment(const std::string& id, + std::unordered_map& states) { + std::vector chain; + std::string cursor = id; + while (chain.size() < 4) { + const auto found = nodes_.find(cursor); + if (found == nodes_.end() || found->second.operation != "transform" || found->second.cacheValid || + found->second.inputs[0].empty()) + break; + chain.push_back(cursor); + cursor = found->second.inputs[0]; + } + if (chain.size() < 2) return nullptr; + std::reverse(chain.begin(), chain.end()); + + const PointSet* input = evaluate(cursor, states); + if (!input) return nullptr; + const bool gpuEligible = + !input->empty() && + (computePolicy_ == "gpu" || (computePolicy_ == "auto" && input->getCount() >= computeMinimumPoints_)); + if (!gpuEligible) return nullptr; + if (executionNodeBudget_ > 0 && evaluatedNodes_ + int(chain.size()) > executionNodeBudget_) { + error_ = "execution node budget exceeded at node: " + id; + lastCancelled_ = true; + return nullptr; + } + + std::vector transforms; + transforms.reserve(chain.size()); + for (const std::string& nodeId : chain) { + const Node& transformNode = nodes_.at(nodeId); + transforms.push_back({floatValue(transformNode, "x", 0.f), floatValue(transformNode, "y", 0.f), + floatValue(transformNode, "z", 0.f), floatValue(transformNode, "yaw", 0.f), + floatValue(transformNode, "scaleX", 1.f), floatValue(transformNode, "scaleY", 1.f), + floatValue(transformNode, "scaleZ", 1.f)}); + } + + PointSet output; + const uint64_t started = nowNanoseconds(); + if (!pointCompute_->transformChain(*input, output, transforms)) { + computeFallbackReason_ = pointCompute_->getError(); + return nullptr; + } + + evaluatedNodes_ += int(chain.size()); + for (const std::string& nodeId : chain) { + Node& transformNode = nodes_.at(nodeId); + transformNode.deferredTransformValid = true; + states[nodeId] = 2; + } + Node& finalNode = nodes_.at(id); + finalNode.cache = std::move(output); + finalNode.cacheValid = true; + finalNode.deferredTransformValid = false; +#ifdef EVENGINE_WEBGPU + const std::string backend = "webgpu"; +#else + const std::string backend = "vulkan"; +#endif + const float elapsed = float(nowNanoseconds() - started) * 0.000001f; + finalNode.cacheMetric = makeMetric(id, finalNode.cache, elapsed, false, backend); + metrics_.push_back(finalNode.cacheMetric); + return &finalNode.cache; +} + +bool PointGraph::validateNode(const std::string& id, std::unordered_map& states) { + if (states[id] == 2) return true; + if (states[id] == 1) { + error_ = "cycle at node: " + id; + return false; + } + const auto found = nodes_.find(id); + if (found == nodes_.end() || !operationKnown(found->second.operation)) { + error_ = "invalid node: " + id; + return false; + } + states[id] = 1; + for (const auto& input : found->second.inputs) { + if (!input.empty() && !validateNode(input, states)) return false; + } + const auto& node = found->second; + if (node.operation == "input" && !node.hasPoints) error_ = "input node has no points: " + id; + else if ((node.operation == "spatial.sample" || node.operation == "spatial.filter" || + node.operation == "spatial.project" || node.operation == "biome.generate") && + !node.spatial) + error_ = "node has no spatial data: " + id; + else if (node.operation == "biome.generate" && !node.biomeRules) + error_ = "node has no biome rules: " + id; + else if (node.operation == "grammar.generate" && !node.shapeGrammar) + error_ = "node has no shape grammar: " + id; + else if (node.operation == "subgraph" && (!node.subgraph || node.inputs[0].empty())) + error_ = "subgraph is not configured: " + id; + else if (node.operation == "subgraph") { + PointGraph nested = *node.subgraph; + PointSet externalInput; + if (!nested.setNodePoints(node.subgraphInput, &externalInput) || !nested.validate()) + error_ = "invalid subgraph at " + id + ": " + nested.getError(); + } else { + const auto* spec = operationSpec(node.operation); + for (int index = 0; spec && index < spec->inputs; ++index) + if (node.inputs[index].empty()) { + error_ = node.operation + " requires input " + std::to_string(index) + ": " + id; + break; + } + } + if (!error_.empty()) return false; + states[id] = 2; + return true; +} + +void PointGraph::invalidate() { clearCache(); } +void PointGraph::invalidateFrom(const std::string& id) { + ++revision_; + std::vector dirty{id}; + std::unordered_map visited; + for (size_t cursor = 0; cursor < dirty.size(); ++cursor) { + const std::string current = dirty[cursor]; + if (visited[current]) continue; + visited[current] = true; + const auto found = nodes_.find(current); + if (found != nodes_.end()) { + found->second.cacheValid = false; + found->second.deferredTransformValid = false; + } + for (const auto& [candidateId, candidate] : nodes_) { + if (candidate.inputs[0] == current || candidate.inputs[1] == current) + dirty.push_back(candidateId); + } + } + metrics_.clear(); +} +float PointGraph::floatValue(const Node& node, const std::string& key, float fallback) const { + for (const auto& name : parameterOrder_) { + const auto& binding = parameters_.at(name); + if (binding.nodeId == node.id && binding.key == key) { + const auto overridden = floatOverrides_.find(name); + if (overridden != floatOverrides_.end()) return overridden->second; + } + } + const auto found = node.floats.find(key); + return found == node.floats.end() ? fallback : found->second; +} +int PointGraph::intValue(const Node& node, const std::string& key, int fallback) const { + for (const auto& name : parameterOrder_) { + const auto& binding = parameters_.at(name); + if (binding.nodeId == node.id && binding.key == key) { + const auto overridden = intOverrides_.find(name); + if (overridden != intOverrides_.end()) return overridden->second; + } + } + const auto found = node.ints.find(key); + return found == node.ints.end() ? fallback : found->second; +} +std::string PointGraph::stringValue(const Node& node, const std::string& key, + const std::string& fallback) const { + for (const auto& name : parameterOrder_) { + const auto& binding = parameters_.at(name); + if (binding.nodeId == node.id && binding.key == key) { + const auto overridden = stringOverrides_.find(name); + if (overridden != stringOverrides_.end()) return overridden->second; + } + } + const auto found = node.strings.find(key); + return found == node.strings.end() ? fallback : found->second; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointGraph.h b/src/modules/procgen/PointGraph.h new file mode 100644 index 00000000..33fa62f3 --- /dev/null +++ b/src/modules/procgen/PointGraph.h @@ -0,0 +1,254 @@ +#pragma once + +#include "procgen/PointSet.h" +#include "procgen/SpatialData.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen { + +class BiomeRules; +class PointCompute; +class ShapeGrammar; + +/** @brief Per-node execution information from the most recent graph run. */ +struct PointGraphNodeMetric { + std::string id; + int outputCount = 0; + float milliseconds = 0.f; + bool cacheHit = false; + std::string backend = "cpu"; + float minX = 0.f; + float minY = 0.f; + float minZ = 0.f; + float maxX = 0.f; + float maxY = 0.f; + float maxZ = 0.f; + float averageDensity = 0.f; +}; + +/** + * @brief A data-flow graph for attributed procedural points. + * + * Nodes are addressed by stable string ids and evaluated lazily from a chosen + * output. Graph mutations invalidate cached results. Execution rejects missing + * inputs and cycles, retains inspectable node outputs, and supports nested + * graphs through the `subgraph` operation. + */ +class PointGraph { +public: + PointGraph(); + /** @brief Add a node using one of the operations returned by operationAt(). */ + bool addNode(const std::string& id, const std::string& operation); + /** @brief Remove a node and every edge that references it. */ + bool removeNode(const std::string& id); + bool hasNode(const std::string& id) const; + int getNodeCount() const; + std::string getNodeId(int index) const; + std::string getNodeOperation(const std::string& id) const; + + /** @brief Connect an output to input slot 0 or 1. Replaces that input connection. */ + bool connect(const std::string& fromId, const std::string& toId, int inputIndex = 0); + /** @brief Remove one input connection. */ + bool disconnect(const std::string& toId, int inputIndex = 0); + std::string getInputNode(const std::string& nodeId, int inputIndex) const; + + /** @brief Assign a copied PointSet to an `input` node. */ + bool setNodePoints(const std::string& id, PointSet* points); + /** @brief Assign copied spatial data to spatial.sample or spatial.filter. */ + bool setNodeSpatial(const std::string& id, SpatialData* spatial); + /** @brief Assign copied biome rules to a `biome.generate` node. */ + bool setNodeBiomeRules(const std::string& id, BiomeRules* rules); + /** @brief Assign a copied shape grammar to a `grammar.generate` node. */ + bool setNodeShapeGrammar(const std::string& id, ShapeGrammar* grammar); + bool setNodeFloat(const std::string& id, const std::string& key, float value); + bool setNodeInt(const std::string& id, const std::string& key, int value); + bool setNodeString(const std::string& id, const std::string& key, const std::string& value); + /** @brief Expose one reflected node parameter for per-instance overrides. */ + bool exposeParameter(const std::string& name, const std::string& nodeId, + const std::string& key); + /** @brief Return the number of exposed graph parameters. */ + int getParameterCount() const; + /** @brief Return an exposed parameter name by stable insertion index. */ + std::string getParameterName(int index) const; + /** @brief Return the reflected kind of an exposed parameter. */ + std::string getParameterKind(const std::string& name) const; + /** @brief Report whether an instance override is active. */ + bool hasParameterOverride(const std::string& name) const; + /** @brief Override an exposed floating-point parameter for this graph instance. */ + bool setParameterFloat(const std::string& name, float value); + /** @brief Override an exposed integer or Boolean parameter for this graph instance. */ + bool setParameterInt(const std::string& name, int value); + /** @brief Override an exposed string parameter for this graph instance. */ + bool setParameterString(const std::string& name, const std::string& value); + /** @brief Return the effective floating-point parameter value. */ + float getParameterFloat(const std::string& name, float fallback) const; + /** @brief Return the effective integer or Boolean parameter value. */ + int getParameterInt(const std::string& name, int fallback) const; + /** @brief Return the effective string parameter value. */ + std::string getParameterString(const std::string& name, const std::string& fallback) const; + /** @brief Remove an instance override and restore the asset default. */ + bool clearParameterOverride(const std::string& name); + /** + * @brief Assign an immutable nested graph. + * @param inputNode Nested `input` node receiving this node's first input. + * @param outputNode Nested node returned by the subgraph. + */ + bool setNodeSubgraph(const std::string& id, PointGraph* graph, const std::string& inputNode, + const std::string& outputNode); + + /** @brief Evaluate one node and return a caller-owned result, or nullptr on failure. */ + PointSet* execute(const std::string& outputId); + /** @brief Limit uncached nodes evaluated by one execute call; zero disables the limit. */ + void setExecutionNodeBudget(int nodes); + int getExecutionNodeBudget() const; + /** @brief Limit points produced by any node; zero disables the limit. */ + void setMaxNodeOutputPoints(int points); + /** @brief Return the per-node point output limit; zero means unlimited. */ + int getMaxNodeOutputPoints() const; + /** @brief Select `auto`, `gpu`, or `cpu` point execution; GPU failures fall back to CPU. */ + bool setComputePolicy(const std::string& policy); + /** @brief Return the configured point compute policy. */ + std::string getComputePolicy() const; + /** @brief Set the minimum point count for `auto` GPU dispatch. */ + void setComputeMinimumPoints(int points); + /** @brief Return the minimum point count for automatic GPU dispatch. */ + int getComputeMinimumPoints() const; + /** @brief Cancel subsequent execution until resetCancellation is called. */ + void requestCancel(); + void resetCancellation(); + bool wasCancelled() const; + /** @brief Validate ids, operations, required inputs and graph acyclicity. */ + bool validate(); + std::string getError() const; + void clearCache(); + /** @brief Monotonic topology/parameter revision used by asset and preview caches. */ + uint64_t getRevision() const; + int getExecutionCount() const; + int getCacheHitCount() const; + + int getMetricCount() const; + std::string getMetricNodeId(int index) const; + int getMetricOutputCount(int index) const; + float getMetricMilliseconds(int index) const; + bool isMetricCacheHit(int index) const; + /** @brief Return `cpu`, `vulkan`, or `webgpu` for one evaluated node. */ + std::string getMetricBackend(int index) const; + /** @brief Return why the latest eligible GPU node used the CPU fallback. */ + std::string getComputeFallbackReason() const; + /** @brief Return successful point-compute uploads for this graph executor. */ + uint64_t getComputeUploadCount() const; + /** @brief Return successful point-compute dispatches for this graph executor. */ + uint64_t getComputeDispatchCount() const; + /** @brief Return successful point-compute readbacks for this graph executor. */ + uint64_t getComputeReadbackCount() const; + /** @brief Return executions that reused allocated point-compute buffers. */ + uint64_t getComputeBufferReuseCount() const; + /** @brief Return peak bytes reserved in each reusable point-compute buffer. */ + uint64_t getComputePeakBufferBytes() const; + /** @brief Return logical transforms in the latest successful fused dispatch. */ + int getLastFusedTransformCount() const; + /** @brief Return cached output bounds for editor/debug visualization. */ + float getMetricMinX(int index) const; + /** @brief Return the cached minimum output Y coordinate. */ + float getMetricMinY(int index) const; + /** @brief Return the cached minimum output Z coordinate. */ + float getMetricMinZ(int index) const; + /** @brief Return the cached maximum output X coordinate. */ + float getMetricMaxX(int index) const; + /** @brief Return the cached maximum output Y coordinate. */ + float getMetricMaxY(int index) const; + /** @brief Return the cached maximum output Z coordinate. */ + float getMetricMaxZ(int index) const; + /** @brief Return the mean point density of one node output. */ + float getMetricAverageDensity(int index) const; + /** @brief Copy the cached/debug output of a node after execution. */ + PointSet* getNodeOutput(const std::string& id) const; + std::string debugReport() const; + /** + * @brief Serialize graph topology, parameters and nested graphs. + * + * Runtime PointSet, SpatialData, BiomeRules and ShapeGrammar values are + * external slots and are not embedded; callers rebind them after loading. + */ + std::string serializeDefinition() const; + /** @brief Transactionally replace this graph from serializeDefinition output. */ + bool deserializeDefinition(const std::string& definition); + /** @brief Create an independent runtime instance without external inputs or overrides. */ + PointGraph* instantiate() const; + + static int getOperationCount(); + static std::string getOperationId(int index); + static int getOperationInputCount(const std::string& operation); + static int getOperationParamCount(const std::string& operation); + static std::string getOperationParamKey(const std::string& operation, int index); + static std::string getOperationParamKind(const std::string& operation, int index); + static std::string getOperationParamDefault(const std::string& operation, int index); + +private: + struct Node { + std::string id; + std::string operation; + std::string inputs[2]; + std::unordered_map floats; + std::unordered_map ints; + std::unordered_map strings; + PointSet points; + bool hasPoints = false; + std::shared_ptr spatial; + std::shared_ptr biomeRules; + std::shared_ptr shapeGrammar; + std::shared_ptr subgraph; + std::string subgraphInput; + std::string subgraphOutput; + PointSet cache; + PointGraphNodeMetric cacheMetric; + bool cacheValid = false; + bool deferredTransformValid = false; + }; + struct ParameterBinding { + std::string nodeId; + std::string key; + std::string kind; + }; + + const PointSet* evaluate(const std::string& id, std::unordered_map& states); + const PointSet* evaluateTransformSegment(const std::string& id, std::unordered_map& states); + PointSet* materializeNodeOutput(const std::string& id) const; + bool validateNode(const std::string& id, + std::unordered_map& states); + void invalidate(); + void invalidateFrom(const std::string& id); + float floatValue(const Node& node, const std::string& key, float fallback) const; + int intValue(const Node& node, const std::string& key, int fallback) const; + std::string stringValue(const Node& node, const std::string& key, + const std::string& fallback = {}) const; + + std::unordered_map nodes_; + std::vector nodeOrder_; + std::unordered_map parameters_; + std::vector parameterOrder_; + std::unordered_map floatOverrides_; + std::unordered_map intOverrides_; + std::unordered_map stringOverrides_; + std::vector metrics_; + std::string error_; + int executionCount_ = 0; + int cacheHitCount_ = 0; + uint64_t revision_ = 0; + int executionNodeBudget_ = 0; + int maxNodeOutputPoints_ = 0; + std::string computePolicy_ = "auto"; + int computeMinimumPoints_ = 1024; + std::string computeFallbackReason_; + std::shared_ptr pointCompute_; + int evaluatedNodes_ = 0; + bool cancelRequested_ = false; + bool lastCancelled_ = false; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointGraphAsset.cpp b/src/modules/procgen/PointGraphAsset.cpp new file mode 100644 index 00000000..30d957ef --- /dev/null +++ b/src/modules/procgen/PointGraphAsset.cpp @@ -0,0 +1,228 @@ +#include "procgen/PointGraph.h" + +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +std::string hexEncode(const std::string& value) { + static constexpr char digits[] = "0123456789abcdef"; + std::string result; + result.reserve(value.size() * 2); + for (const unsigned char byte : value) { + result.push_back(digits[byte >> 4u]); + result.push_back(digits[byte & 0x0fu]); + } + return result; +} + +int hexDigit(char value) { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + if (value >= 'A' && value <= 'F') return value - 'A' + 10; + return -1; +} + +bool hexDecode(const std::string& value, std::string& result) { + if (value.size() % 2 != 0) return false; + result.clear(); + result.reserve(value.size() / 2); + for (size_t i = 0; i < value.size(); i += 2) { + const int high = hexDigit(value[i]); + const int low = hexDigit(value[i + 1]); + if (high < 0 || low < 0) return false; + result.push_back(char((high << 4) | low)); + } + return true; +} + +} // namespace + +std::string PointGraph::serializeDefinition() const { + std::ostringstream out; + out << "EVPCG_POINT_GRAPH 1\n"; + out << std::setprecision(9); + for (const auto& id : nodeOrder_) { + const auto& node = nodes_.at(id); + out << "NODE " << std::quoted(id) << ' ' << std::quoted(node.operation) << '\n'; + std::vector keys; + keys.reserve(node.floats.size()); + for (const auto& [key, value] : node.floats) keys.push_back(key); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) + out << "FLOAT " << std::quoted(id) << ' ' << std::quoted(key) << ' ' + << node.floats.at(key) << '\n'; + keys.clear(); + keys.reserve(node.ints.size()); + for (const auto& [key, value] : node.ints) keys.push_back(key); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) + out << "INT " << std::quoted(id) << ' ' << std::quoted(key) << ' ' + << node.ints.at(key) << '\n'; + keys.clear(); + keys.reserve(node.strings.size()); + for (const auto& [key, value] : node.strings) keys.push_back(key); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) + out << "STRING " << std::quoted(id) << ' ' << std::quoted(key) << ' ' + << std::quoted(node.strings.at(key)) << '\n'; + if (node.subgraph) { + out << "SUBGRAPH " << std::quoted(id) << ' ' << std::quoted(node.subgraphInput) << ' ' + << std::quoted(node.subgraphOutput) << ' ' + << std::quoted(hexEncode(node.subgraph->serializeDefinition())) << '\n'; + } + } + std::vector parameterNames = parameterOrder_; + std::sort(parameterNames.begin(), parameterNames.end()); + for (const auto& name : parameterNames) { + const auto& parameter = parameters_.at(name); + out << "PARAM " << std::quoted(name) << ' ' << std::quoted(parameter.nodeId) << ' ' + << std::quoted(parameter.key) << '\n'; + } + for (const auto& id : nodeOrder_) { + const auto& node = nodes_.at(id); + for (int input = 0; input < 2; ++input) + if (!node.inputs[input].empty()) + out << "EDGE " << std::quoted(node.inputs[input]) << ' ' << std::quoted(id) << ' ' + << input << '\n'; + } + out << "END\n"; + return out.str(); +} + +bool PointGraph::deserializeDefinition(const std::string& definition) { + PointGraph replacement; + std::istringstream input(definition); + std::string magic; + int version = 0; + if (!(input >> magic >> version) || magic != "EVPCG_POINT_GRAPH" || version != 1) { + error_ = "invalid point graph header"; + return false; + } + std::string line; + std::getline(input, line); + bool ended = false; + while (std::getline(input, line)) { + if (line.empty()) continue; + std::istringstream record(line); + std::string kind; + record >> kind; + if (kind == "END") { + ended = true; + break; + } + std::string id; + if (kind == "NODE") { + std::string operation; + if (!(record >> std::quoted(id) >> std::quoted(operation)) || + !replacement.addNode(id, operation)) { + error_ = "invalid NODE record"; + return false; + } + } else if (kind == "EDGE") { + std::string to; + int slot = -1; + if (!(record >> std::quoted(id) >> std::quoted(to) >> slot) || + !replacement.connect(id, to, slot)) { + error_ = "invalid EDGE record"; + return false; + } + } else if (kind == "FLOAT") { + std::string key; + float value = 0.f; + if (!(record >> std::quoted(id) >> std::quoted(key) >> value) || + !replacement.setNodeFloat(id, key, value)) { + error_ = "invalid FLOAT record"; + return false; + } + } else if (kind == "INT") { + std::string key; + int value = 0; + if (!(record >> std::quoted(id) >> std::quoted(key) >> value) || + !replacement.setNodeInt(id, key, value)) { + error_ = "invalid INT record"; + return false; + } + } else if (kind == "STRING") { + std::string key; + std::string value; + if (!(record >> std::quoted(id) >> std::quoted(key) >> std::quoted(value)) || + !replacement.setNodeString(id, key, value)) { + error_ = "invalid STRING record"; + return false; + } + } else if (kind == "SUBGRAPH") { + std::string nestedInput; + std::string nestedOutput; + std::string nestedEncoded; + std::string nestedDefinition; + if (!(record >> std::quoted(id) >> std::quoted(nestedInput) >> + std::quoted(nestedOutput) >> std::quoted(nestedEncoded)) || + !hexDecode(nestedEncoded, nestedDefinition)) { + error_ = "invalid SUBGRAPH record"; + return false; + } + PointGraph nested; + if (!nested.deserializeDefinition(nestedDefinition) || + !replacement.setNodeSubgraph(id, &nested, nestedInput, nestedOutput)) { + error_ = "invalid nested graph at " + id + ": " + nested.getError(); + return false; + } + } else if (kind == "PARAM") { + std::string nodeId; + std::string key; + if (!(record >> std::quoted(id) >> std::quoted(nodeId) >> std::quoted(key)) || + !replacement.exposeParameter(id, nodeId, key)) { + error_ = "invalid PARAM record"; + return false; + } + } else { + error_ = "unknown graph record: " + kind; + return false; + } + record >> std::ws; + if (!record.eof()) { + error_ = "trailing graph record data: " + kind; + return false; + } + } + if (!ended) { + error_ = "point graph END record is missing"; + return false; + } + + // Structural cycle check without requiring runtime PointSet/SpatialData slots. + std::unordered_map states; + std::function visit = [&](const std::string& id) { + if (states[id] == 2) return true; + if (states[id] == 1) return false; + states[id] = 1; + const auto found = replacement.nodes_.find(id); + if (found == replacement.nodes_.end()) return false; + for (const auto& dependency : found->second.inputs) + if (!dependency.empty() && !visit(dependency)) return false; + states[id] = 2; + return true; + }; + for (const auto& id : replacement.nodeOrder_) { + if (!visit(id)) { + error_ = "cycle in serialized graph at node: " + id; + return false; + } + } + *this = std::move(replacement); + error_.clear(); + return true; +} + +PointGraph* PointGraph::instantiate() const { + auto* instance = new PointGraph(); + if (instance->deserializeDefinition(serializeDefinition())) return instance; + delete instance; + return nullptr; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointGraphScript.cpp b/src/modules/procgen/PointGraphScript.cpp new file mode 100644 index 00000000..93b00407 --- /dev/null +++ b/src/modules/procgen/PointGraphScript.cpp @@ -0,0 +1,110 @@ +#include "procgen/PointGraphScript.h" + +#include "procgen/PointGraph.h" + +#include + +#include + +namespace eve::procgen { + +void exposePointGraph(ssq::Table& table) { + auto graph = table.addClass( + "ProcgenPointGraph", std::function([]() -> PointGraph* { return nullptr; }), + true); + graph.addFunc("addNode", &PointGraph::addNode); + graph.addFunc("removeNode", &PointGraph::removeNode); + graph.addFunc("hasNode", &PointGraph::hasNode); + graph.addFunc("getNodeCount", &PointGraph::getNodeCount); + graph.addFunc("getNodeId", &PointGraph::getNodeId); + graph.addFunc("getNodeOperation", &PointGraph::getNodeOperation); + graph.addFunc("connect", &PointGraph::connect); + graph.addFunc("disconnect", &PointGraph::disconnect); + graph.addFunc("getInputNode", &PointGraph::getInputNode); + graph.addFunc("setNodePoints", &PointGraph::setNodePoints); + graph.addFunc("setNodeSpatial", &PointGraph::setNodeSpatial); + graph.addFunc("setNodeBiomeRules", &PointGraph::setNodeBiomeRules); + graph.addFunc("setNodeShapeGrammar", &PointGraph::setNodeShapeGrammar); + graph.addFunc("setNodeFloat", &PointGraph::setNodeFloat); + graph.addFunc("setNodeInt", &PointGraph::setNodeInt); + graph.addFunc("setNodeString", &PointGraph::setNodeString); + graph.addFunc("exposeParameter", &PointGraph::exposeParameter); + graph.addFunc("getParameterCount", &PointGraph::getParameterCount); + graph.addFunc("getParameterName", &PointGraph::getParameterName); + graph.addFunc("getParameterKind", &PointGraph::getParameterKind); + graph.addFunc("hasParameterOverride", &PointGraph::hasParameterOverride); + graph.addFunc("setParameterFloat", &PointGraph::setParameterFloat); + graph.addFunc("setParameterInt", &PointGraph::setParameterInt); + graph.addFunc("setParameterString", &PointGraph::setParameterString); + graph.addFunc("getParameterFloat", &PointGraph::getParameterFloat); + graph.addFunc("getParameterInt", &PointGraph::getParameterInt); + graph.addFunc("getParameterString", &PointGraph::getParameterString); + graph.addFunc("clearParameterOverride", &PointGraph::clearParameterOverride); + graph.addFunc("setNodeSubgraph", &PointGraph::setNodeSubgraph); + graph.addFunc("execute", &PointGraph::execute); + graph.addFunc("setExecutionNodeBudget", &PointGraph::setExecutionNodeBudget); + graph.addFunc("getExecutionNodeBudget", &PointGraph::getExecutionNodeBudget); + graph.addFunc("setMaxNodeOutputPoints", &PointGraph::setMaxNodeOutputPoints); + graph.addFunc("getMaxNodeOutputPoints", &PointGraph::getMaxNodeOutputPoints); + graph.addFunc("setComputePolicy", &PointGraph::setComputePolicy); + graph.addFunc("getComputePolicy", &PointGraph::getComputePolicy); + graph.addFunc("setComputeMinimumPoints", &PointGraph::setComputeMinimumPoints); + graph.addFunc("getComputeMinimumPoints", &PointGraph::getComputeMinimumPoints); + graph.addFunc("requestCancel", &PointGraph::requestCancel); + graph.addFunc("resetCancellation", &PointGraph::resetCancellation); + graph.addFunc("wasCancelled", &PointGraph::wasCancelled); + graph.addFunc("validate", &PointGraph::validate); + graph.addFunc("getError", &PointGraph::getError); + graph.addFunc("clearCache", &PointGraph::clearCache); + graph.addFunc("getRevision", &PointGraph::getRevision); + graph.addFunc("getExecutionCount", &PointGraph::getExecutionCount); + graph.addFunc("getCacheHitCount", &PointGraph::getCacheHitCount); + graph.addFunc("getMetricCount", &PointGraph::getMetricCount); + graph.addFunc("getMetricNodeId", &PointGraph::getMetricNodeId); + graph.addFunc("getMetricOutputCount", &PointGraph::getMetricOutputCount); + graph.addFunc("getMetricMilliseconds", &PointGraph::getMetricMilliseconds); + graph.addFunc("isMetricCacheHit", &PointGraph::isMetricCacheHit); + graph.addFunc("getMetricBackend", &PointGraph::getMetricBackend); + graph.addFunc("getComputeFallbackReason", &PointGraph::getComputeFallbackReason); + graph.addFunc("getComputeUploadCount", &PointGraph::getComputeUploadCount); + graph.addFunc("getComputeDispatchCount", &PointGraph::getComputeDispatchCount); + graph.addFunc("getComputeReadbackCount", &PointGraph::getComputeReadbackCount); + graph.addFunc("getComputeBufferReuseCount", &PointGraph::getComputeBufferReuseCount); + graph.addFunc("getComputePeakBufferBytes", &PointGraph::getComputePeakBufferBytes); + graph.addFunc("getLastFusedTransformCount", &PointGraph::getLastFusedTransformCount); + graph.addFunc("getMetricMinX", &PointGraph::getMetricMinX); + graph.addFunc("getMetricMinY", &PointGraph::getMetricMinY); + graph.addFunc("getMetricMinZ", &PointGraph::getMetricMinZ); + graph.addFunc("getMetricMaxX", &PointGraph::getMetricMaxX); + graph.addFunc("getMetricMaxY", &PointGraph::getMetricMaxY); + graph.addFunc("getMetricMaxZ", &PointGraph::getMetricMaxZ); + graph.addFunc("getMetricAverageDensity", &PointGraph::getMetricAverageDensity); + graph.addFunc("getNodeOutput", &PointGraph::getNodeOutput); + graph.addFunc("debugReport", &PointGraph::debugReport); + graph.addFunc("serializeDefinition", &PointGraph::serializeDefinition); + graph.addFunc("deserializeDefinition", &PointGraph::deserializeDefinition); + graph.addFunc("instantiate", &PointGraph::instantiate); + graph.addFunc("getOperationCount", [](PointGraph*) { return PointGraph::getOperationCount(); }); + graph.addFunc("getOperationId", + [](PointGraph*, int index) { return PointGraph::getOperationId(index); }); + graph.addFunc("getOperationInputCount", [](PointGraph*, const std::string& operation) { + return PointGraph::getOperationInputCount(operation); + }); + graph.addFunc("getOperationParamCount", [](PointGraph*, const std::string& operation) { + return PointGraph::getOperationParamCount(operation); + }); + graph.addFunc("getOperationParamKey", + [](PointGraph*, const std::string& operation, int index) { + return PointGraph::getOperationParamKey(operation, index); + }); + graph.addFunc("getOperationParamKind", + [](PointGraph*, const std::string& operation, int index) { + return PointGraph::getOperationParamKind(operation, index); + }); + graph.addFunc("getOperationParamDefault", + [](PointGraph*, const std::string& operation, int index) { + return PointGraph::getOperationParamDefault(operation, index); + }); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointGraphScript.h b/src/modules/procgen/PointGraphScript.h new file mode 100644 index 00000000..ea6efd5b --- /dev/null +++ b/src/modules/procgen/PointGraphScript.h @@ -0,0 +1,12 @@ +#pragma once + +namespace ssq { +class Table; +} + +namespace eve::procgen { + +/** @brief Register PointGraph script types on the Procgen module table. */ +void exposePointGraph(ssq::Table& table); + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointSet.cpp b/src/modules/procgen/PointSet.cpp index 83e352a7..6aba3254 100644 --- a/src/modules/procgen/PointSet.cpp +++ b/src/modules/procgen/PointSet.cpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace eve::procgen { namespace { @@ -212,6 +213,88 @@ PointSet sampleGridPoints(int width, int depth, float spacing, uint32_t seed, fl return output; } +PointSet poissonDiskPoints(int width, int depth, float radius, uint32_t seed, int maxPoints) { + PointSet output; + if (width <= 0 || depth <= 0 || radius <= 0.f) return output; + maxPoints = std::max(0, maxPoints); + + // Bridson's algorithm: candidate annulus [r, 2r], grid cell side r / sqrt(2). + const float cell = radius * 0.70710678118f; + const int gridW = std::max(1, int(std::ceil(float(width) / cell))); + const int gridH = std::max(1, int(std::ceil(float(depth) / cell))); + std::vector grid(size_t(gridW) * size_t(gridH), -1); + + std::vector xs, ys; + std::mt19937 rng(seed); + std::uniform_real_distribution unit(0.f, 1.f); + + const auto cellIndex = [&](float x, float y) -> int { + int gx = int(x / cell); + int gy = int(y / cell); + gx = std::max(0, std::min(gx, gridW - 1)); + gy = std::max(0, std::min(gy, gridH - 1)); + return gy * gridW + gx; + }; + const auto inBounds = [&](float x, float y) { return x >= 0.f && x <= float(width) && y >= 0.f && y <= float(depth); }; + const auto tooClose = [&](float x, float y, float r2) { + const int gx = std::max(0, std::min(int(x / cell), gridW - 1)); + const int gy = std::max(0, std::min(int(y / cell), gridH - 1)); + for (int oy = -2; oy <= 2; ++oy) { + for (int ox = -2; ox <= 2; ++ox) { + const int nx = gx + ox, ny = gy + oy; + if (nx < 0 || nx >= gridW || ny < 0 || ny >= gridH) continue; + const int idx = grid[ny * gridW + nx]; + if (idx < 0) continue; + const float dx = x - xs[size_t(idx)], dy = y - ys[size_t(idx)]; + if (dx * dx + dy * dy < r2) return true; + } + } + return false; + }; + + // Seed with one random interior point. + float fx = unit(rng) * float(width); + float fy = unit(rng) * float(depth); + xs.push_back(fx); + ys.push_back(fy); + grid[cellIndex(fx, fy)] = 0; + std::vector active{0}; + + const float r2 = radius * radius; + constexpr int kTries = 30; + while (!active.empty() && int(xs.size()) < maxPoints) { + const int pick = int(unit(rng) * float(active.size())); + const int base = active[size_t(std::min(pick, int(active.size()) - 1))]; + bool found = false; + for (int k = 0; k < kTries; ++k) { + const float theta = unit(rng) * 6.28318530718f; + const float dist = radius * (1.f + unit(rng)); + const float nx = xs[size_t(base)] + std::cos(theta) * dist; + const float ny = ys[size_t(base)] + std::sin(theta) * dist; + if (!inBounds(nx, ny) || tooClose(nx, ny, r2)) continue; + xs.push_back(nx); + ys.push_back(ny); + grid[cellIndex(nx, ny)] = int(xs.size()) - 1; + active.push_back(int(xs.size()) - 1); + found = true; + break; + } + if (!found) active.erase(active.begin() + pick); + } + + output.points().reserve(xs.size()); + for (size_t i = 0; i < xs.size(); ++i) { + ProcgenPoint point; + point.x = xs[i]; + point.y = 0.f; + point.z = ys[i]; + point.seed = mix32(seed ^ uint32_t(i)); + if (point.seed == 0) point.seed = 1; + output.points().push_back(std::move(point)); + } + return output; +} + PointSet filterPointHeight(const PointSet& input, float minHeight, float maxHeight) { PointSet output; if (minHeight > maxHeight) std::swap(minHeight, maxHeight); @@ -369,4 +452,164 @@ PointSet samplePolylinePoints(const PointSet& controlPoints, float spacing, uint return output; } +PointSet mergePointSets(const PointSet& first, const PointSet& second) { + PointSet output; + output.points().reserve(first.points().size() + second.points().size()); + output.points().insert(output.points().end(), first.points().begin(), first.points().end()); + output.points().insert(output.points().end(), second.points().begin(), second.points().end()); + return output; +} + +PointSet transformPointSet(const PointSet& input, float translateX, float translateY, + float translateZ, float yawDegrees, float scaleX, float scaleY, + float scaleZ) { + PointSet output = input; + constexpr float degreesToRadians = 0.017453292519943295f; + const float radians = yawDegrees * degreesToRadians; + const float cosine = std::cos(radians); + const float sine = std::sin(radians); + for (auto& point : output.points()) { + const float x = point.x * scaleX; + const float z = point.z * scaleZ; + point.x = x * cosine - z * sine + translateX; + point.y = point.y * scaleY + translateY; + point.z = x * sine + z * cosine + translateZ; + point.yaw += yawDegrees; + point.scaleX *= scaleX; + point.scaleY *= scaleY; + point.scaleZ *= scaleZ; + + const float nx = point.normalX / (std::abs(scaleX) > 0.000001f ? scaleX : 1.f); + const float ny = point.normalY / (std::abs(scaleY) > 0.000001f ? scaleY : 1.f); + const float nz = point.normalZ / (std::abs(scaleZ) > 0.000001f ? scaleZ : 1.f); + point.normalX = nx * cosine - nz * sine; + point.normalY = ny; + point.normalZ = nx * sine + nz * cosine; + const float normalLength = std::sqrt(point.normalX * point.normalX + + point.normalY * point.normalY + + point.normalZ * point.normalZ); + if (normalLength > 0.f) { + point.normalX /= normalLength; + point.normalY /= normalLength; + point.normalZ /= normalLength; + } + } + return output; +} + +PointSet copyPointsToTargets(const PointSet& source, const PointSet& targets, + bool inheritTargetAttributes) { + constexpr float degreesToRadians = 0.017453292519943295f; + PointSet result; + if (!targets.points().empty() && + source.points().size() > + std::numeric_limits::max() / targets.points().size()) + return result; + result.points().reserve(source.points().size() * targets.points().size()); + for (const auto& target : targets.points()) { + const float radians = target.yaw * degreesToRadians; + const float cosine = std::cos(radians); + const float sine = std::sin(radians); + for (const auto& sourcePoint : source.points()) { + ProcgenPoint copy = sourcePoint; + const float localX = sourcePoint.x * target.scaleX; + const float localY = sourcePoint.y * target.scaleY; + const float localZ = sourcePoint.z * target.scaleZ; + copy.x = target.x + localX * cosine - localZ * sine; + copy.y = target.y + localY; + copy.z = target.z + localX * sine + localZ * cosine; + const float normalX = sourcePoint.normalX * cosine - sourcePoint.normalZ * sine; + const float normalZ = sourcePoint.normalX * sine + sourcePoint.normalZ * cosine; + copy.normalX = normalX; + copy.normalZ = normalZ; + copy.yaw += target.yaw; + copy.scaleX *= target.scaleX; + copy.scaleY *= target.scaleY; + copy.scaleZ *= target.scaleZ; + copy.density *= target.density; + copy.seed = deriveSeed(target.seed, "copy:" + std::to_string(sourcePoint.seed)); + if (inheritTargetAttributes) { + auto sourceFloats = std::move(copy.floatAttributes); + auto sourceStrings = std::move(copy.stringAttributes); + copy.floatAttributes = target.floatAttributes; + copy.stringAttributes = target.stringAttributes; + for (auto& [name, value] : sourceFloats) + copy.floatAttributes[name] = value; + for (auto& [name, value] : sourceStrings) + copy.stringAttributes[name] = std::move(value); + } + result.points().push_back(std::move(copy)); + } + } + return result; +} + +PointSet remapPointDensity(const PointSet& input, float inputMin, float inputMax, + float outputMin, float outputMax, bool clampOutput) { + PointSet result = input; + const float inputRange = inputMax - inputMin; + for (auto& point : result.points()) { + float normalized = (point.density - inputMin) / inputRange; + if (clampOutput) normalized = std::clamp(normalized, 0.f, 1.f); + point.density = outputMin + normalized * (outputMax - outputMin); + } + return result; +} + +PointSet mathPointFloatAttribute(const PointSet& input, const std::string& attribute, + const std::string& outputAttribute, + const std::string& operation, float operand, + float defaultValue) { + PointSet result = input; + for (auto& point : result.points()) { + const auto found = point.floatAttributes.find(attribute); + const float value = found == point.floatAttributes.end() ? defaultValue : found->second; + float output = value; + if (operation == "add") output += operand; + else if (operation == "subtract") output -= operand; + else if (operation == "multiply") output *= operand; + else if (operation == "divide") output /= operand; + else if (operation == "min") output = std::min(output, operand); + else if (operation == "max") output = std::max(output, operand); + point.floatAttributes[outputAttribute] = output; + } + return result; +} + +PointSet filterPointFloatAttribute(const PointSet& input, const std::string& name, float minValue, + float maxValue, bool invert) { + if (minValue > maxValue) std::swap(minValue, maxValue); + PointSet output; + for (const auto& point : input.points()) { + const auto found = point.floatAttributes.find(name); + const bool matches = found != point.floatAttributes.end() && found->second >= minValue && + found->second <= maxValue; + if (matches != invert) output.points().push_back(point); + } + return output; +} + +PointSet filterPointStringAttribute(const PointSet& input, const std::string& name, + const std::string& value, bool invert) { + PointSet output; + for (const auto& point : input.points()) { + const auto found = point.stringAttributes.find(name); + const bool matches = found != point.stringAttributes.end() && found->second == value; + if (matches != invert) output.points().push_back(point); + } + return output; +} + +PointSet densityCullPoints(const PointSet& input, uint32_t seed, float multiplier) { + PointSet output; + multiplier = std::max(0.f, multiplier); + for (size_t i = 0; i < input.points().size(); ++i) { + const auto& point = input.points()[i]; + const uint32_t branchSeed = mix32(seed ^ point.seed ^ uint32_t(i)); + const float chance = std::clamp(point.density * multiplier, 0.f, 1.f); + if (unitFloat(branchSeed) < chance) output.points().push_back(point); + } + return output; +} + } // namespace eve::procgen diff --git a/src/modules/procgen/PointSet.h b/src/modules/procgen/PointSet.h index 997a6759..8ad7a911 100644 --- a/src/modules/procgen/PointSet.h +++ b/src/modules/procgen/PointSet.h @@ -87,6 +87,8 @@ private: uint32_t deriveSeed(uint32_t parent, const std::string& scope); PointSet sampleGridPoints(int width, int depth, float spacing, uint32_t seed, float jitter); +/** @brief Bridson blue-noise (Poisson disk) samples in a width x depth area (XZ, y=0). */ +PointSet poissonDiskPoints(int width, int depth, float radius, uint32_t seed, int maxPoints); PointSet filterPointHeight(const PointSet& input, float minHeight, float maxHeight); PointSet filterPointDensity(const PointSet& input, float minDensity, float maxDensity); PointSet filterPointBox(const PointSet& input, float minX, float minY, float minZ, float maxX, @@ -103,5 +105,30 @@ PointSet projectPointsToHeightmap(const PointSet& input, const Heightmap& height float heightScale); PointSet samplePolylinePoints(const PointSet& controlPoints, float spacing, uint32_t seed, float lateralJitter); +/** @brief Concatenate two attributed point collections while preserving order. */ +PointSet mergePointSets(const PointSet& first, const PointSet& second); +/** @brief Apply translation, yaw rotation and non-uniform scale to points and their transforms. */ +PointSet transformPointSet(const PointSet& input, float translateX, float translateY, + float translateZ, float yawDegrees, float scaleX, float scaleY, + float scaleZ); +/** @brief Instantiate source points relative to targets in stable target-major order. */ +PointSet copyPointsToTargets(const PointSet& source, const PointSet& targets, + bool inheritTargetAttributes); +/** @brief Linearly remap point density between ranges with optional output clamping. */ +PointSet remapPointDensity(const PointSet& input, float inputMin, float inputMax, + float outputMin, float outputMax, bool clampOutput); +/** @brief Apply one scalar operation to a float metadata attribute. */ +PointSet mathPointFloatAttribute(const PointSet& input, const std::string& attribute, + const std::string& outputAttribute, + const std::string& operation, float operand, + float defaultValue); +/** @brief Select points whose named float attribute lies in an inclusive range. */ +PointSet filterPointFloatAttribute(const PointSet& input, const std::string& name, float minValue, + float maxValue, bool invert); +/** @brief Select points whose named string attribute equals a value. */ +PointSet filterPointStringAttribute(const PointSet& input, const std::string& name, + const std::string& value, bool invert); +/** @brief Deterministically keep points according to density and a root seed. */ +PointSet densityCullPoints(const PointSet& input, uint32_t seed, float multiplier); } // namespace eve::procgen diff --git a/src/modules/procgen/Procgen.cpp b/src/modules/procgen/Procgen.cpp index cdc71738..7359ac94 100644 --- a/src/modules/procgen/Procgen.cpp +++ b/src/modules/procgen/Procgen.cpp @@ -1,6 +1,11 @@ #include "procgen/Procgen.h" #include "common/SquirrelBinding.h" +#include "common/Capability.h" +#include "common/ProcgenSceneSink.h" +#include "procgen/BiomeScript.h" +#include "procgen/PointGraphScript.h" +#include "procgen/ShapeGrammarScript.h" #include "procgen/ProcgenCapabilities.h" #include "image/ImageData.h" @@ -26,8 +31,10 @@ #include #include #include +#include #include #include +#include #include namespace eve::procgen { @@ -585,6 +592,246 @@ eve::Result Procgen::sampleSplineHandle( samplePolylinePoints(*control, spacing, seed, lateralJitter))); } +eve::Result Procgen::mergePointsHandle( + ProcgenPointSetHandleRef first, ProcgenPointSetHandleRef second) { + auto a = resolvePointSet(first); + auto b = resolvePointSet(second); + if (!a.isBound() || !b.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "mergePoints input handle is stale", "points"); + Procgen* module = Procgen::create(); + return ownProcgenObject(module->ownership_->points, + std::make_unique(mergePointSets(*a, *b))); +} + +eve::Result Procgen::transformPointsHandle( + ProcgenPointSetHandleRef input, float translateX, float translateY, float translateZ, + float yawDegrees, float scaleX, float scaleY, float scaleZ) { + auto view = resolvePointSet(input); + if (!view.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "transformPoints input handle is stale", "input"); + Procgen* module = Procgen::create(); + return ownProcgenObject(module->ownership_->points, + std::make_unique(transformPointSet( + *view, translateX, translateY, translateZ, yawDegrees, + scaleX, scaleY, scaleZ))); +} + +eve::Result Procgen::filterFloatAttributeHandle( + ProcgenPointSetHandleRef input, const std::string& name, float minValue, float maxValue, + bool invert) { + auto view = resolvePointSet(input); + if (!view.isBound() || name.empty()) + return procgenBindingFailure( + name.empty() ? eve::DiagnosticCode::InvalidArgument : eve::DiagnosticCode::StaleHandle, + "filterFloatAttribute requires a live input and attribute name", "input"); + Procgen* module = Procgen::create(); + return ownProcgenObject(module->ownership_->points, + std::make_unique(filterPointFloatAttribute( + *view, name, minValue, maxValue, invert))); +} + +eve::Result Procgen::filterStringAttributeHandle( + ProcgenPointSetHandleRef input, const std::string& name, const std::string& value, + bool invert) { + auto view = resolvePointSet(input); + if (!view.isBound() || name.empty()) + return procgenBindingFailure( + name.empty() ? eve::DiagnosticCode::InvalidArgument : eve::DiagnosticCode::StaleHandle, + "filterStringAttribute requires a live input and attribute name", "input"); + Procgen* module = Procgen::create(); + return ownProcgenObject(module->ownership_->points, + std::make_unique(filterPointStringAttribute( + *view, name, value, invert))); +} + +eve::Result Procgen::densityCullHandle( + ProcgenPointSetHandleRef input, uint32_t seed, float multiplier) { + auto view = resolvePointSet(input); + if (!view.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "densityCull input handle is stale", "input"); + Procgen* module = Procgen::create(); + return ownProcgenObject(module->ownership_->points, + std::make_unique(densityCullPoints(*view, seed, multiplier))); +} + +eve::Result Procgen::pointDataHandle( + ProcgenPointSetHandleRef points) { + auto view = resolvePointSet(points); + if (!view.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "pointData point-set handle is stale", "points"); + return spatialData_.emplace(std::make_unique(SpatialData::fromPoints(*view))); +} + +eve::Result Procgen::boxVolumeHandle( + float minX, float minY, float minZ, float maxX, float maxY, float maxZ) { + return spatialData_.emplace(std::make_unique( + SpatialData::box(minX, minY, minZ, maxX, maxY, maxZ))); +} + +eve::Result Procgen::sphereVolumeHandle( + float x, float y, float z, float radius) { + if (radius <= 0.f) + return procgenBindingFailure( + eve::DiagnosticCode::InvalidArgument, "sphereVolume radius must be positive", "radius"); + return spatialData_.emplace( + std::make_unique(SpatialData::sphere(x, y, z, radius))); +} + +eve::Result Procgen::splineDataHandle( + ProcgenPointSetHandleRef controlPoints, float radius) { + auto view = resolvePointSet(controlPoints); + if (!view.isBound() || view->getCount() < 2 || radius < 0.f) + return procgenBindingFailure( + !view.isBound() ? eve::DiagnosticCode::StaleHandle : eve::DiagnosticCode::InvalidArgument, + "splineData requires a live set with at least two points and non-negative radius", + "controlPoints"); + return spatialData_.emplace( + std::make_unique(SpatialData::spline(*view, radius))); +} + +eve::Result Procgen::heightfieldDataHandle( + ProcgenHeightmapHandleRef heightmap, float originX, float originZ, float cellSize, + float heightScale) { + auto view = resolveHeightmap(heightmap); + if (!view.isBound() || view->getWidth() <= 0 || view->getHeight() <= 0 || cellSize <= 0.f) + return procgenBindingFailure( + !view.isBound() ? eve::DiagnosticCode::StaleHandle : eve::DiagnosticCode::InvalidArgument, + "heightfieldData requires a live non-empty heightmap and positive cell size", "heightmap"); + return spatialData_.emplace(std::make_unique( + SpatialData::heightfield(*view, originX, originZ, cellSize, heightScale))); +} + +eve::Result Procgen::unionSpatialHandle( + ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right) { + auto a = resolveSpatialData(left); auto b = resolveSpatialData(right); + if (!a.isBound() || !b.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "unionSpatial input handle is stale", "spatial"); + return spatialData_.emplace(std::make_unique(SpatialData::unite(*a, *b))); +} + +eve::Result Procgen::intersectSpatialHandle( + ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right) { + auto a = resolveSpatialData(left); auto b = resolveSpatialData(right); + if (!a.isBound() || !b.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "intersectSpatial input handle is stale", "spatial"); + return spatialData_.emplace(std::make_unique(SpatialData::intersect(*a, *b))); +} + +eve::Result Procgen::differenceSpatialHandle( + ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right) { + auto a = resolveSpatialData(left); auto b = resolveSpatialData(right); + if (!a.isBound() || !b.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "differenceSpatial input handle is stale", "spatial"); + return spatialData_.emplace(std::make_unique(SpatialData::subtract(*a, *b))); +} + +eve::Result Procgen::sampleSpatialHandle( + ProcgenSpatialDataHandleRef spatial, float spacing, uint32_t seed, float jitter) { + auto view = resolveSpatialData(spatial); + if (!view.isBound() || spacing <= 0.f) + return procgenBindingFailure( + !view.isBound() ? eve::DiagnosticCode::StaleHandle : eve::DiagnosticCode::InvalidArgument, + "sampleSpatial requires live spatial data and positive spacing", "spatial"); + return ownership_->points.emplace( + std::make_unique(view->sample(spacing, seed, jitter))); +} + +eve::Result Procgen::filterSpatialHandle( + ProcgenPointSetHandleRef input, ProcgenSpatialDataHandleRef spatial, bool invert) { + auto points = resolvePointSet(input); auto domain = resolveSpatialData(spatial); + if (!points.isBound() || !domain.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "filterSpatial input handle is stale", "input"); + return ownership_->points.emplace( + std::make_unique(domain->filter(*points, invert))); +} + +eve::Result Procgen::projectToSpatialHandle( + ProcgenPointSetHandleRef input, ProcgenSpatialDataHandleRef spatial) { + auto points = resolvePointSet(input); auto domain = resolveSpatialData(spatial); + if (!points.isBound() || !domain.isBound()) + return procgenBindingFailure( + eve::DiagnosticCode::StaleHandle, "projectToSpatial input handle is stale", "input"); + return ownership_->points.emplace( + std::make_unique(domain->project(*points))); +} + +eve::script::Borrowed Procgen::resolveSpatialData( + ProcgenSpatialDataHandleRef reference) noexcept { return spatialData_.resolve(reference); } + +eve::Result Procgen::release(ProcgenSpatialDataHandleRef reference) { + return spatialData_.erase(reference); +} + +bool Procgen::isStale(ProcgenSpatialDataHandleRef reference) const noexcept { + return spatialData_.isStale(reference); +} + +eve::Result Procgen::newRuntimeGenerationHandle( + uint32_t worldSeed) { + return runtimeGenerations_.emplace(std::make_unique(worldSeed)); +} + +eve::Result Procgen::newLSystemHandle() { + return lsystems_.emplace(std::make_unique()); +} + +eve::script::Borrowed Procgen::resolveRuntimeGeneration( + ProcgenRuntimeGenerationHandleRef reference) noexcept { + return runtimeGenerations_.resolve(reference); +} + +eve::script::Borrowed Procgen::resolvePointGraph( + ProcgenPointGraphHandleRef reference) noexcept { return pointGraphs_.resolve(reference); } + +eve::script::Borrowed Procgen::resolveBiomeRules( + ProcgenBiomeRulesHandleRef reference) noexcept { return biomeRules_.resolve(reference); } + +eve::script::Borrowed Procgen::resolveShapeGrammar( + ProcgenShapeGrammarHandleRef reference) noexcept { return shapeGrammars_.resolve(reference); } + +eve::script::Borrowed Procgen::resolveLSystem( + ProcgenLSystemHandleRef reference) noexcept { return lsystems_.resolve(reference); } + +eve::Result Procgen::release(ProcgenRuntimeGenerationHandleRef reference) { + return runtimeGenerations_.erase(reference); +} +eve::Result Procgen::release(ProcgenPointGraphHandleRef reference) { + return pointGraphs_.erase(reference); +} +eve::Result Procgen::release(ProcgenBiomeRulesHandleRef reference) { + return biomeRules_.erase(reference); +} +eve::Result Procgen::release(ProcgenShapeGrammarHandleRef reference) { + return shapeGrammars_.erase(reference); +} +eve::Result Procgen::release(ProcgenLSystemHandleRef reference) { + return lsystems_.erase(reference); +} + +bool Procgen::isStale(ProcgenRuntimeGenerationHandleRef reference) const noexcept { + return runtimeGenerations_.isStale(reference); +} +bool Procgen::isStale(ProcgenPointGraphHandleRef reference) const noexcept { + return pointGraphs_.isStale(reference); +} +bool Procgen::isStale(ProcgenBiomeRulesHandleRef reference) const noexcept { + return biomeRules_.isStale(reference); +} +bool Procgen::isStale(ProcgenShapeGrammarHandleRef reference) const noexcept { + return shapeGrammars_.isStale(reference); +} +bool Procgen::isStale(ProcgenLSystemHandleRef reference) const noexcept { + return lsystems_.isStale(reference); +} + uint32_t Procgen::deriveSeed(uint32_t parent, const std::string& scope) const { return eve::procgen::deriveSeed(parent, scope); } @@ -593,6 +840,7 @@ eve::Result Procgen::beginSystemHandle( const std::string& name, uint32_t seed) { Procgen* module = Procgen::create(); module->lastError_.clear(); + if (name.empty()) { module->lastError_ = "beginSystem: name is empty"; return procgenBindingFailure( @@ -1597,6 +1845,9 @@ void Procgen::expose(ssq::Table &table) { const HSQUIRRELVM vm = table.getHandle(); auto cls = table.addClass(name, Procgen::create, false); expose(cls); + exposeBiomeRules(table); + exposePointGraph(table); + exposeShapeGrammar(table); auto recipe = table.addClass( "ProcgenRecipeSchema", @@ -1704,6 +1955,132 @@ void Procgen::expose(ssq::Table &table) { points.addFunc("getStringAttribute", &PointSet::getStringAttribute); points.addFunc("hasStringAttribute", &PointSet::hasStringAttribute); +auto lsystem = table.addClass( + "ProcgenLSystem", std::function([]() -> LSystem* { return nullptr; }), true); + lsystem.addFunc("setAxiom", &LSystem::setAxiom); + lsystem.addFunc("addRule", &LSystem::addRule); + lsystem.addFunc("addRules", + [](LSystem* ls, char symbol, ssq::Array productions, ssq::Array weights) { + ls->addRules(symbol, productions.convert(), + weights.convert()); + }); + lsystem.addFunc("clearRules", &LSystem::clearRules); + lsystem.addFunc("setAngle", &LSystem::setAngle); + lsystem.addFunc("setStep", &LSystem::setStep); + lsystem.addFunc("setIterations", &LSystem::setIterations); + lsystem.addFunc("setSeed", &LSystem::setSeed); + lsystem.addFunc("getSeed", &LSystem::getSeed); + lsystem.addFunc("getIterations", &LSystem::getIterations); + lsystem.addFunc("setInitialHeading", &LSystem::setInitialHeading); + lsystem.addFunc("setBranchRadius", &LSystem::setBranchRadius); + lsystem.addFunc("setBranchRadiusFalloff", &LSystem::setBranchRadiusFalloff); + lsystem.addFunc("setLeafSize", &LSystem::setLeafSize); + lsystem.addFunc("setLeafSymbols", &LSystem::setLeafSymbols); + lsystem.addFunc("setTropism", &LSystem::setTropism); + lsystem.addFunc("derive", &LSystem::derive); + lsystem.addFunc("trace", [](LSystem* ls, PointSet* out) { + if (!out) throw std::invalid_argument("trace: null PointSet"); + ls->toPointSet(*out); + }); + + auto spatial = table.addClass( + "ProcgenSpatialData", + std::function([]() -> SpatialData* { return nullptr; }), true); + spatial.addFunc("getKind", &SpatialData::getKind); + spatial.addFunc("contains", &SpatialData::contains); + spatial.addFunc("hasBounds", &SpatialData::hasBounds); + spatial.addFunc("getMinX", &SpatialData::getMinX); + spatial.addFunc("getMinY", &SpatialData::getMinY); + spatial.addFunc("getMinZ", &SpatialData::getMinZ); + spatial.addFunc("getMaxX", &SpatialData::getMaxX); + spatial.addFunc("getMaxY", &SpatialData::getMaxY); + spatial.addFunc("getMaxZ", &SpatialData::getMaxZ); + + auto cellRequest = table.addClass( + "ProcgenCellRequest", + std::function([]() -> ProcgenCellRequest* { return nullptr; }), + true); + cellRequest.addFunc("getLevel", &ProcgenCellRequest::getLevel); + cellRequest.addFunc("getX", &ProcgenCellRequest::getX); + cellRequest.addFunc("getZ", &ProcgenCellRequest::getZ); + cellRequest.addFunc("getSeed", &ProcgenCellRequest::getSeed); + cellRequest.addFunc("getTicket", &ProcgenCellRequest::getTicket); + cellRequest.addFunc("getMinX", &ProcgenCellRequest::getMinX); + cellRequest.addFunc("getMinZ", &ProcgenCellRequest::getMinZ); + cellRequest.addFunc("getMaxX", &ProcgenCellRequest::getMaxX); + cellRequest.addFunc("getMaxZ", &ProcgenCellRequest::getMaxZ); + + auto runtimeGeneration = table.addClass( + "ProcgenRuntimeGeneration", + std::function([]() -> RuntimeGeneration* { return nullptr; }), true); + runtimeGeneration.addFunc("clear", &RuntimeGeneration::clear); + runtimeGeneration.addFunc("addLevel", &RuntimeGeneration::addLevel); + runtimeGeneration.addFunc("getLevelCount", &RuntimeGeneration::getLevelCount); + runtimeGeneration.addFunc("getLevelCellSize", &RuntimeGeneration::getLevelCellSize); + runtimeGeneration.addFunc("getLevelGenerationRadius", + &RuntimeGeneration::getLevelGenerationRadius); + runtimeGeneration.addFunc("getLevelCleanupRadius", &RuntimeGeneration::getLevelCleanupRadius); + runtimeGeneration.addFunc("setDirectionWeight", &RuntimeGeneration::setDirectionWeight); + runtimeGeneration.addFunc("getDirectionWeight", &RuntimeGeneration::getDirectionWeight); + runtimeGeneration.addFunc("setMaxGenerating", &RuntimeGeneration::setMaxGenerating); + runtimeGeneration.addFunc("getMaxGenerating", &RuntimeGeneration::getMaxGenerating); + runtimeGeneration.addFunc("setMaxActiveCells", &RuntimeGeneration::setMaxActiveCells); + runtimeGeneration.addFunc("getMaxActiveCells", &RuntimeGeneration::getMaxActiveCells); + runtimeGeneration.addFunc("setMaxPointsPerCell", &RuntimeGeneration::setMaxPointsPerCell); + runtimeGeneration.addFunc("getMaxPointsPerCell", &RuntimeGeneration::getMaxPointsPerCell); + runtimeGeneration.addFunc("setMaxResidentPoints", &RuntimeGeneration::setMaxResidentPoints); + runtimeGeneration.addFunc("getMaxResidentPoints", &RuntimeGeneration::getMaxResidentPoints); + runtimeGeneration.addFunc("getResidentPointCount", + &RuntimeGeneration::getResidentPointCount); + runtimeGeneration.addFunc("getRejectedOutputCount", + &RuntimeGeneration::getRejectedOutputCount); + runtimeGeneration.addFunc("trimToResidentPoints", &RuntimeGeneration::trimToResidentPoints); + runtimeGeneration.addFunc("setMaxGenerationRetries", + &RuntimeGeneration::setMaxGenerationRetries); + runtimeGeneration.addFunc("getMaxGenerationRetries", + &RuntimeGeneration::getMaxGenerationRetries); + runtimeGeneration.addFunc("setFrameTimeBudget", &RuntimeGeneration::setFrameTimeBudget); + runtimeGeneration.addFunc("getFrameTimeBudget", &RuntimeGeneration::getFrameTimeBudget); + runtimeGeneration.addFunc("beginFrame", &RuntimeGeneration::beginFrame); + runtimeGeneration.addFunc("updateSource", &RuntimeGeneration::updateSource); + runtimeGeneration.addFunc("setGenerationSource", &RuntimeGeneration::setGenerationSource); + runtimeGeneration.addFunc("removeGenerationSource", + &RuntimeGeneration::removeGenerationSource); + runtimeGeneration.addFunc("clearGenerationSources", + &RuntimeGeneration::clearGenerationSources); + runtimeGeneration.addFunc("getGenerationSourceCount", + &RuntimeGeneration::getGenerationSourceCount); + runtimeGeneration.addFunc("getGenerationSourceId", + &RuntimeGeneration::getGenerationSourceId); + runtimeGeneration.addFunc("refreshGenerationSources", + &RuntimeGeneration::refreshGenerationSources); + runtimeGeneration.addFunc("setFrustumCulling", &RuntimeGeneration::setFrustumCulling); + runtimeGeneration.addFunc("isFrustumCullingEnabled", + &RuntimeGeneration::isFrustumCullingEnabled); + runtimeGeneration.addFunc("getFrustumHalfAngle", + &RuntimeGeneration::getFrustumHalfAngle); + runtimeGeneration.addFunc("getFrustumBehindRadius", + &RuntimeGeneration::getFrustumBehindRadius); + runtimeGeneration.addFunc("getPendingGenerateCount", + &RuntimeGeneration::getPendingGenerateCount); + runtimeGeneration.addFunc("getGeneratingCount", &RuntimeGeneration::getGeneratingCount); + runtimeGeneration.addFunc("getActiveCellCount", &RuntimeGeneration::getActiveCellCount); + runtimeGeneration.addFunc("getPendingCleanupCount", + &RuntimeGeneration::getPendingCleanupCount); + runtimeGeneration.addFunc("getFailedCellCount", &RuntimeGeneration::getFailedCellCount); + runtimeGeneration.addFunc("retryFailedCells", &RuntimeGeneration::retryFailedCells); + runtimeGeneration.addFunc("nextGenerate", &RuntimeGeneration::nextGenerate); + runtimeGeneration.addFunc("nextCleanup", &RuntimeGeneration::nextCleanup); + runtimeGeneration.addFunc("completeGeneration", &RuntimeGeneration::completeGeneration); + runtimeGeneration.addFunc("failGeneration", &RuntimeGeneration::failGeneration); + runtimeGeneration.addFunc("completeCleanup", &RuntimeGeneration::completeCleanup); + runtimeGeneration.addFunc("hasCell", &RuntimeGeneration::hasCell); + runtimeGeneration.addFunc("getCellOutput", &RuntimeGeneration::getCellOutput); + runtimeGeneration.addFunc("getCellRevision", &RuntimeGeneration::getCellRevision); + runtimeGeneration.addFunc("serializeCell", &RuntimeGeneration::serializeCell); + runtimeGeneration.addFunc("deserializeCell", &RuntimeGeneration::deserializeCell); + runtimeGeneration.addFunc("debugReport", &RuntimeGeneration::debugReport); + auto context = table.addClass( "ProcgenContext", std::function([]() -> ProcgenContext* { return nullptr; }), true); diff --git a/src/modules/procgen/Procgen.h b/src/modules/procgen/Procgen.h index 9a3b2cdb..46ddcc24 100644 --- a/src/modules/procgen/Procgen.h +++ b/src/modules/procgen/Procgen.h @@ -3,8 +3,20 @@ #include "common/Module.h" #include "common/SquirrelOwnership.h" #include "procgen/core/ProcgenCore.h" +#include "procgen/Biome.h" +#include "procgen/Grid2D.h" +#include "procgen/MeshBuild.h" #include "procgen/OutputSpec.h" #include "procgen/Palette.h" +#include "procgen/ParamSchema.h" +#include "procgen/Params.h" +#include "procgen/PointSet.h" +#include "procgen/PointGraph.h" +#include "procgen/ProcgenSystem.h" +#include "procgen/RuntimeGeneration.h" +#include "procgen/ShapeGrammar.h" +#include "procgen/SpatialData.h" +#include "procgen/algorithms/LSystem.h" #include "procgen/heightmap/Heightmap.h" #include "procgen/heightmap/TerrainSampler.h" #include "procgen/texture/CloudField.h" @@ -55,6 +67,18 @@ struct ProcgenMeshBuildHandleTag {}; struct ProcgenImageHandleTag {}; /** @brief Handle domain for module-owned generated normal images. */ struct ProcgenNormalImageHandleTag {}; +/** @brief Handle domain for module-owned spatial data. */ +struct ProcgenSpatialDataHandleTag {}; +/** @brief Handle domain for module-owned runtime-generation schedulers. */ +struct ProcgenRuntimeGenerationHandleTag {}; +/** @brief Handle domain for module-owned point graphs. */ +struct ProcgenPointGraphHandleTag {}; +/** @brief Handle domain for module-owned biome rules. */ +struct ProcgenBiomeRulesHandleTag {}; +/** @brief Handle domain for module-owned shape grammars. */ +struct ProcgenShapeGrammarHandleTag {}; +/** @brief Handle domain for module-owned L-system engines. */ +struct ProcgenLSystemHandleTag {}; using ProcgenParamsHandleRef = eve::script::RuntimeHandleRef; using ProcgenGridHandleRef = eve::script::RuntimeHandleRef; using ProcgenContextHandleRef = eve::script::RuntimeHandleRef; @@ -68,6 +92,12 @@ using ProcgenPbrMaterialHandleRef = eve::script::RuntimeHandleRef; using ProcgenImageHandleRef = eve::script::RuntimeHandleRef; using ProcgenNormalImageHandleRef = eve::script::RuntimeHandleRef; +using ProcgenSpatialDataHandleRef = eve::script::RuntimeHandleRef; +using ProcgenRuntimeGenerationHandleRef = eve::script::RuntimeHandleRef; +using ProcgenPointGraphHandleRef = eve::script::RuntimeHandleRef; +using ProcgenBiomeRulesHandleRef = eve::script::RuntimeHandleRef; +using ProcgenShapeGrammarHandleRef = eve::script::RuntimeHandleRef; +using ProcgenLSystemHandleRef = eve::script::RuntimeHandleRef; /** * @brief Procedural generation module. @@ -152,6 +182,92 @@ public: [[nodiscard]] eve::Result sampleSplineHandle( ProcgenPointSetHandleRef controlPoints, float spacing, uint32_t seed, float lateralJitter); + [[nodiscard]] eve::Result poissonDiskHandle( + int width, int depth, float radius, uint32_t seed, int maxPoints); + [[nodiscard]] eve::Result mergePointsHandle( + ProcgenPointSetHandleRef first, ProcgenPointSetHandleRef second); + [[nodiscard]] eve::Result transformPointsHandle( + ProcgenPointSetHandleRef input, float translateX, float translateY, float translateZ, + float yawDegrees, float scaleX, float scaleY, float scaleZ); + [[nodiscard]] eve::Result filterFloatAttributeHandle( + ProcgenPointSetHandleRef input, const std::string& name, float minValue, float maxValue, + bool invert); + [[nodiscard]] eve::Result filterStringAttributeHandle( + ProcgenPointSetHandleRef input, const std::string& name, const std::string& value, + bool invert); + [[nodiscard]] eve::Result densityCullHandle( + ProcgenPointSetHandleRef input, uint32_t seed, float multiplier); + + // --- Spatial data and composable PCG domains --- + [[nodiscard]] eve::Result pointDataHandle( + ProcgenPointSetHandleRef points); + [[nodiscard]] eve::Result boxVolumeHandle( + float minX, float minY, float minZ, float maxX, float maxY, float maxZ); + [[nodiscard]] eve::Result sphereVolumeHandle( + float x, float y, float z, float radius); + [[nodiscard]] eve::Result splineDataHandle( + ProcgenPointSetHandleRef controlPoints, float radius); + [[nodiscard]] eve::Result heightfieldDataHandle( + ProcgenHeightmapHandleRef heightmap, float originX, float originZ, float cellSize, + float heightScale); + [[nodiscard]] eve::Result unionSpatialHandle( + ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right); + [[nodiscard]] eve::Result intersectSpatialHandle( + ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right); + [[nodiscard]] eve::Result differenceSpatialHandle( + ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right); + [[nodiscard]] eve::Result sampleSpatialHandle( + ProcgenSpatialDataHandleRef spatial, float spacing, uint32_t seed, float jitter); + [[nodiscard]] eve::Result filterSpatialHandle( + ProcgenPointSetHandleRef input, ProcgenSpatialDataHandleRef spatial, bool invert); + [[nodiscard]] eve::Result projectToSpatialHandle( + ProcgenPointSetHandleRef input, ProcgenSpatialDataHandleRef spatial); + + [[nodiscard]] eve::Result newRuntimeGenerationHandle( + uint32_t worldSeed); + [[nodiscard]] eve::Result newPointGraphHandle(); + [[nodiscard]] eve::Result newBiomeRulesHandle(); + [[nodiscard]] eve::Result newShapeGrammarHandle(); + [[nodiscard]] eve::Result newLSystemHandle(); + [[nodiscard]] eve::script::Borrowed resolveSpatialData( + ProcgenSpatialDataHandleRef reference) noexcept; + [[nodiscard]] eve::script::Borrowed resolveRuntimeGeneration( + ProcgenRuntimeGenerationHandleRef reference) noexcept; + [[nodiscard]] eve::script::Borrowed resolvePointGraph( + ProcgenPointGraphHandleRef reference) noexcept; + [[nodiscard]] eve::script::Borrowed resolveBiomeRules( + ProcgenBiomeRulesHandleRef reference) noexcept; + [[nodiscard]] eve::script::Borrowed resolveShapeGrammar( + ProcgenShapeGrammarHandleRef reference) noexcept; + [[nodiscard]] eve::script::Borrowed resolveLSystem( + ProcgenLSystemHandleRef reference) noexcept; + [[nodiscard]] eve::Result release(ProcgenSpatialDataHandleRef reference); + [[nodiscard]] eve::Result release(ProcgenRuntimeGenerationHandleRef reference); + [[nodiscard]] eve::Result release(ProcgenPointGraphHandleRef reference); + [[nodiscard]] eve::Result release(ProcgenBiomeRulesHandleRef reference); + [[nodiscard]] eve::Result release(ProcgenShapeGrammarHandleRef reference); + [[nodiscard]] eve::Result release(ProcgenLSystemHandleRef reference); + [[nodiscard]] bool isStale(ProcgenSpatialDataHandleRef reference) const noexcept; + [[nodiscard]] bool isStale(ProcgenRuntimeGenerationHandleRef reference) const noexcept; + [[nodiscard]] bool isStale(ProcgenPointGraphHandleRef reference) const noexcept; + [[nodiscard]] bool isStale(ProcgenBiomeRulesHandleRef reference) const noexcept; + [[nodiscard]] bool isStale(ProcgenShapeGrammarHandleRef reference) const noexcept; + [[nodiscard]] bool isStale(ProcgenLSystemHandleRef reference) const noexcept; + + [[nodiscard]] eve::Result publishInstances( + const std::string& batchId, ProcgenPointSetHandleRef points, + const std::string& assetAttribute, const std::string& defaultAsset); + [[nodiscard]] eve::Result removeInstances(const std::string& batchId); + [[nodiscard]] eve::Result publishCellInstances( + const std::string& prefix, const ProcgenCellRequest& request, + ProcgenPointSetHandleRef points, const std::string& assetAttribute, + const std::string& defaultAsset); + [[nodiscard]] eve::Result removeCellInstances( + const std::string& prefix, const ProcgenCellRequest& request); + int getPublishedInstanceCount(const std::string& batchId) const; + int getPublishedCreatedCount(const std::string& batchId) const; + int getPublishedReusedCount(const std::string& batchId) const; + int getPublishedRemovedCount(const std::string& batchId) const; uint32_t deriveSeed(uint32_t parent, const std::string& scope) const; // --- Atomic script rebuilds --- @@ -506,6 +622,12 @@ private: eve::script::RuntimeObjectRegistry params_; eve::script::RuntimeObjectRegistry grids_; eve::script::RuntimeObjectRegistry contexts_; + eve::script::RuntimeObjectRegistry spatialData_; + eve::script::RuntimeObjectRegistry runtimeGenerations_; + eve::script::RuntimeObjectRegistry pointGraphs_; + eve::script::RuntimeObjectRegistry biomeRules_; + eve::script::RuntimeObjectRegistry shapeGrammars_; + eve::script::RuntimeObjectRegistry lsystems_; }; } // namespace eve::procgen diff --git a/src/modules/procgen/ProcgenBiome.cpp b/src/modules/procgen/ProcgenBiome.cpp new file mode 100644 index 00000000..8e3a326d --- /dev/null +++ b/src/modules/procgen/ProcgenBiome.cpp @@ -0,0 +1,11 @@ +#include "procgen/Procgen.h" + +#include "procgen/Biome.h" + +namespace eve::procgen { + +eve::Result Procgen::newBiomeRulesHandle() { + return biomeRules_.emplace(std::make_unique()); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/ProcgenGraph.cpp b/src/modules/procgen/ProcgenGraph.cpp new file mode 100644 index 00000000..cba4eb09 --- /dev/null +++ b/src/modules/procgen/ProcgenGraph.cpp @@ -0,0 +1,11 @@ +#include "procgen/Procgen.h" + +#include "procgen/PointGraph.h" + +namespace eve::procgen { + +eve::Result Procgen::newPointGraphHandle() { + return pointGraphs_.emplace(std::make_unique()); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/ProcgenShapeGrammar.cpp b/src/modules/procgen/ProcgenShapeGrammar.cpp new file mode 100644 index 00000000..8abc9984 --- /dev/null +++ b/src/modules/procgen/ProcgenShapeGrammar.cpp @@ -0,0 +1,11 @@ +#include "procgen/Procgen.h" + +#include "procgen/ShapeGrammar.h" + +namespace eve::procgen { + +eve::Result Procgen::newShapeGrammarHandle() { + return shapeGrammars_.emplace(std::make_unique()); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/RuntimeGeneration.cpp b/src/modules/procgen/RuntimeGeneration.cpp new file mode 100644 index 00000000..5a6f50f4 --- /dev/null +++ b/src/modules/procgen/RuntimeGeneration.cpp @@ -0,0 +1,468 @@ +#include "procgen/RuntimeGeneration.h" + +#include "procgen/PointSet.h" + +#include +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +float distanceToCell(float x, float z, int cellX, int cellZ, float size) { + const float centerX = (float(cellX) + 0.5f) * size; + const float centerZ = (float(cellZ) + 0.5f) * size; + const float dx = centerX - x; + const float dz = centerZ - z; + return std::sqrt(dx * dx + dz * dz); +} + +} // namespace + +int ProcgenCellRequest::getLevel() const { return level_; } +int ProcgenCellRequest::getX() const { return x_; } +int ProcgenCellRequest::getZ() const { return z_; } +uint32_t ProcgenCellRequest::getSeed() const { return seed_; } +uint64_t ProcgenCellRequest::getTicket() const { return ticket_; } +float ProcgenCellRequest::getMinX() const { return float(x_) * cellSize_; } +float ProcgenCellRequest::getMinZ() const { return float(z_) * cellSize_; } +float ProcgenCellRequest::getMaxX() const { return float(x_ + 1) * cellSize_; } +float ProcgenCellRequest::getMaxZ() const { return float(z_ + 1) * cellSize_; } + +RuntimeGeneration::RuntimeGeneration(uint32_t worldSeed) : worldSeed_(worldSeed ? worldSeed : 1u) {} + +void RuntimeGeneration::clear() { + levels_.clear(); + cells_.clear(); + generateQueue_.clear(); + cleanupQueue_.clear(); + sources_.clear(); + sourceOrder_.clear(); + rejectedOutputCount_ = 0; +} + +int RuntimeGeneration::addLevel(float cellSize, float generationRadius, float cleanupMultiplier) { + if (cellSize <= 0.f || generationRadius < 0.f || cleanupMultiplier < 1.f) return -1; + levels_.push_back({cellSize, generationRadius, generationRadius * cleanupMultiplier}); + return int(levels_.size()) - 1; +} + +int RuntimeGeneration::getLevelCount() const { return int(levels_.size()); } +float RuntimeGeneration::getLevelCellSize(int level) const { + return level >= 0 && level < int(levels_.size()) ? levels_[size_t(level)].cellSize : 0.f; +} +float RuntimeGeneration::getLevelGenerationRadius(int level) const { + return level >= 0 && level < int(levels_.size()) + ? levels_[size_t(level)].generationRadius + : 0.f; +} +float RuntimeGeneration::getLevelCleanupRadius(int level) const { + return level >= 0 && level < int(levels_.size()) ? levels_[size_t(level)].cleanupRadius : 0.f; +} + +void RuntimeGeneration::setDirectionWeight(float weight) { + directionWeight_ = std::clamp(weight, 0.f, 1.f); +} +float RuntimeGeneration::getDirectionWeight() const { return directionWeight_; } +void RuntimeGeneration::setMaxGenerating(int count) { maxGenerating_ = std::max(1, count); } +int RuntimeGeneration::getMaxGenerating() const { return maxGenerating_; } +void RuntimeGeneration::setMaxActiveCells(int count) { maxActiveCells_ = std::max(0, count); } +int RuntimeGeneration::getMaxActiveCells() const { return maxActiveCells_; } +void RuntimeGeneration::setMaxPointsPerCell(int count) { maxPointsPerCell_ = std::max(0, count); } +int RuntimeGeneration::getMaxPointsPerCell() const { return maxPointsPerCell_; } +void RuntimeGeneration::setMaxResidentPoints(int count) { + maxResidentPoints_ = std::max(0, count); +} +int RuntimeGeneration::getMaxResidentPoints() const { return maxResidentPoints_; } +int RuntimeGeneration::getResidentPointCount() const { + uint64_t count = 0; + for (const auto& [key, cell] : cells_) + count += uint64_t(cell.output.getCount()); + return int(std::min(count, uint64_t(std::numeric_limits::max()))); +} +int RuntimeGeneration::getRejectedOutputCount() const { return rejectedOutputCount_; } +int RuntimeGeneration::trimToResidentPoints(int target) { + target = std::max(0, target); + uint64_t projectedResident = 0; + for (const auto& [key, cell] : cells_) + if (cell.state != State::Cleanup) + projectedResident += uint64_t(cell.output.getCount()); + if (projectedResident <= uint64_t(target)) return 0; + std::vector candidates; + for (const auto& [key, cell] : cells_) + if (cell.state == State::Active) candidates.push_back(key); + std::sort(candidates.begin(), candidates.end(), [this](const CellKey& a, const CellKey& b) { + const float aPriority = cells_.at(a).priority; + const float bPriority = cells_.at(b).priority; + if (aPriority != bPriority) return aPriority > bPriority; + if (a.level != b.level) return a.level > b.level; + if (a.z != b.z) return a.z > b.z; + return a.x > b.x; + }); + int scheduled = 0; + for (const auto& key : candidates) { + if (projectedResident <= uint64_t(target)) break; + auto& cell = cells_.at(key); + projectedResident -= uint64_t(cell.output.getCount()); + cell.state = State::Cleanup; + cell.trimmed = true; + cell.ticket = ++nextTicket_; + cleanupQueue_.push_back(key); + ++scheduled; + } + sortQueues(); + return scheduled; +} +void RuntimeGeneration::setMaxGenerationRetries(int count) { + maxGenerationRetries_ = std::max(0, count); +} +int RuntimeGeneration::getMaxGenerationRetries() const { return maxGenerationRetries_; } +void RuntimeGeneration::setFrameTimeBudget(float milliseconds) { + frameTimeBudgetMs_ = std::max(0.f, milliseconds); +} +float RuntimeGeneration::getFrameTimeBudget() const { return frameTimeBudgetMs_; } +void RuntimeGeneration::beginFrame() { + frameStartedNs_ = uint64_t(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +void RuntimeGeneration::updateSource(float x, float z, float directionX, float directionZ) { + setGenerationSource("primary", x, z, directionX, directionZ, 1.f); +} + +bool RuntimeGeneration::setGenerationSource(const std::string& id, float x, float z, + float directionX, float directionZ, + float radiusScale) { + if (id.empty() || radiusScale <= 0.f) return false; + const float directionLength = std::sqrt(directionX * directionX + directionZ * directionZ); + Source source; + source.x = x; + source.z = z; + source.directionX = directionLength > 0.f ? directionX / directionLength : 0.f; + source.directionZ = directionLength > 0.f ? directionZ / directionLength : 0.f; + source.radiusScale = radiusScale; + if (sources_.find(id) == sources_.end()) sourceOrder_.push_back(id); + sources_[id] = source; + refreshGenerationSources(); + return true; +} + +bool RuntimeGeneration::removeGenerationSource(const std::string& id) { + if (sources_.erase(id) == 0) return false; + sourceOrder_.erase(std::remove(sourceOrder_.begin(), sourceOrder_.end(), id), + sourceOrder_.end()); + refreshGenerationSources(); + return true; +} + +void RuntimeGeneration::clearGenerationSources() { + sources_.clear(); + sourceOrder_.clear(); + refreshGenerationSources(); +} + +int RuntimeGeneration::getGenerationSourceCount() const { return int(sourceOrder_.size()); } +std::string RuntimeGeneration::getGenerationSourceId(int index) const { + return index >= 0 && index < int(sourceOrder_.size()) ? sourceOrder_[size_t(index)] + : std::string(); +} + +void RuntimeGeneration::setFrustumCulling(bool enabled, float halfAngleDegrees, + float behindRadius) { + frustumCulling_ = enabled; + frustumHalfAngle_ = std::clamp(halfAngleDegrees, 1.f, 180.f); + frustumBehindRadius_ = std::max(0.f, behindRadius); + refreshGenerationSources(); +} +bool RuntimeGeneration::isFrustumCullingEnabled() const { return frustumCulling_; } +float RuntimeGeneration::getFrustumHalfAngle() const { return frustumHalfAngle_; } +float RuntimeGeneration::getFrustumBehindRadius() const { return frustumBehindRadius_; } + +void RuntimeGeneration::refreshGenerationSources() { + constexpr float degreesToRadians = 0.017453292519943295f; + const float coneCosine = std::cos(frustumHalfAngle_ * degreesToRadians); + std::unordered_set desired; + for (auto& [key, cell] : cells_) + if (cell.state != State::Cleanup) cell.priority = std::numeric_limits::max(); + + for (const auto& sourceId : sourceOrder_) { + const auto& source = sources_.at(sourceId); + for (size_t levelIndex = 0; levelIndex < levels_.size(); ++levelIndex) { + const auto& level = levels_[levelIndex]; + const float generationRadius = level.generationRadius * source.radiusScale; + const int minX = int(std::floor((source.x - generationRadius) / level.cellSize)); + const int maxX = int(std::floor((source.x + generationRadius) / level.cellSize)); + const int minZ = int(std::floor((source.z - generationRadius) / level.cellSize)); + const int maxZ = int(std::floor((source.z + generationRadius) / level.cellSize)); + for (int cellZ = minZ; cellZ <= maxZ; ++cellZ) { + for (int cellX = minX; cellX <= maxX; ++cellX) { + const float centerX = (float(cellX) + 0.5f) * level.cellSize; + const float centerZ = (float(cellZ) + 0.5f) * level.cellSize; + const float dx = centerX - source.x; + const float dz = centerZ - source.z; + const float distance = std::sqrt(dx * dx + dz * dz); + if (distance > generationRadius) continue; + const bool hasDirection = + source.directionX != 0.f || source.directionZ != 0.f; + const float forward = distance > 0.f && hasDirection + ? (dx * source.directionX + dz * source.directionZ) / + distance + : 1.f; + if (frustumCulling_ && hasDirection && distance > frustumBehindRadius_ && + forward < coneCosine) + continue; + const CellKey key{int(levelIndex), cellX, cellZ}; + desired.insert(key); + const float priority = distance / std::max(generationRadius, 0.0001f) - + directionWeight_ * forward; + const auto existing = cells_.find(key); + if (existing != cells_.end()) { + if (existing->second.state == State::Cleanup) { + if (existing->second.trimmed) continue; + existing->second.ticket = ++nextTicket_; + existing->second.state = existing->second.revision > 0 ? State::Active + : State::Pending; + existing->second.trimmed = false; + cleanupQueue_.erase( + std::remove(cleanupQueue_.begin(), cleanupQueue_.end(), key), + cleanupQueue_.end()); + if (existing->second.state == State::Pending) + generateQueue_.push_back(key); + } + existing->second.priority = + std::min(existing->second.priority, priority); + continue; + } + Cell cell; + cell.priority = priority; + cells_.emplace(key, cell); + generateQueue_.push_back(key); + } + } + } + } + + for (auto& [key, cell] : cells_) { + if (cell.state == State::Cleanup) continue; + if ((cell.state == State::Pending || cell.state == State::Generating) && + desired.find(key) == desired.end()) { + cell.state = State::Cleanup; + cell.trimmed = false; + cell.ticket = ++nextTicket_; + cleanupQueue_.push_back(key); + continue; + } + const auto& level = levels_[size_t(key.level)]; + bool retained = false; + for (const auto& sourceId : sourceOrder_) { + const auto& source = sources_.at(sourceId); + if (distanceToCell(source.x, source.z, key.x, key.z, level.cellSize) <= + level.cleanupRadius * source.radiusScale) { + retained = true; + break; + } + } + if (retained) continue; + cell.state = State::Cleanup; + cell.trimmed = false; + cell.ticket = ++nextTicket_; + cleanupQueue_.push_back(key); + } + generateQueue_.erase( + std::remove_if(generateQueue_.begin(), generateQueue_.end(), [this](const CellKey& key) { + const auto found = cells_.find(key); + return found == cells_.end() || found->second.state != State::Pending; + }), + generateQueue_.end()); + sortQueues(); +} + +void RuntimeGeneration::sortQueues() { + const auto priorityLess = [this](const CellKey& a, const CellKey& b) { + const float aPriority = cells_.at(a).priority; + const float bPriority = cells_.at(b).priority; + if (aPriority != bPriority) return aPriority < bPriority; + if (a.level != b.level) return a.level < b.level; + if (a.z != b.z) return a.z < b.z; + return a.x < b.x; + }; + std::sort(generateQueue_.begin(), generateQueue_.end(), priorityLess); + std::sort(cleanupQueue_.begin(), cleanupQueue_.end(), [](const CellKey& a, const CellKey& b) { + if (a.level != b.level) return a.level < b.level; + if (a.z != b.z) return a.z < b.z; + return a.x < b.x; + }); +} + +int RuntimeGeneration::getPendingGenerateCount() const { return int(generateQueue_.size()); } +int RuntimeGeneration::getGeneratingCount() const { + return int(std::count_if(cells_.begin(), cells_.end(), [](const auto& entry) { + return entry.second.state == State::Generating; + })); +} +int RuntimeGeneration::getActiveCellCount() const { + return int(std::count_if(cells_.begin(), cells_.end(), [](const auto& entry) { + return entry.second.state == State::Active; + })); +} +int RuntimeGeneration::getPendingCleanupCount() const { return int(cleanupQueue_.size()); } +int RuntimeGeneration::getFailedCellCount() const { + return int(std::count_if(cells_.begin(), cells_.end(), [](const auto& entry) { + return entry.second.state == State::Failed; + })); +} + +int RuntimeGeneration::retryFailedCells() { + int count = 0; + for (auto& [key, cell] : cells_) { + if (cell.state != State::Failed) continue; + cell.state = State::Pending; + cell.failures = 0; + cell.ticket = ++nextTicket_; + generateQueue_.push_back(key); + ++count; + } + sortQueues(); + return count; +} + +ProcgenCellRequest* RuntimeGeneration::nextGenerate() { + if (generateQueue_.empty() || getGeneratingCount() >= maxGenerating_) return nullptr; + if (maxActiveCells_ > 0 && getActiveCellCount() + getGeneratingCount() >= maxActiveCells_) + return nullptr; + if (frameTimeBudgetMs_ > 0.f && frameStartedNs_ != 0) { + const uint64_t now = uint64_t(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); + if (float(now - frameStartedNs_) * 0.000001f >= frameTimeBudgetMs_) return nullptr; + } + const CellKey key = generateQueue_.front(); + generateQueue_.erase(generateQueue_.begin()); + const auto found = cells_.find(key); + if (found == cells_.end() || found->second.state != State::Pending) return nextGenerate(); + found->second.state = State::Generating; + found->second.ticket = ++nextTicket_; + return makeRequest(key); +} + +ProcgenCellRequest* RuntimeGeneration::nextCleanup() { + if (cleanupQueue_.empty()) return nullptr; + const CellKey key = cleanupQueue_.front(); + cleanupQueue_.erase(cleanupQueue_.begin()); + const auto found = cells_.find(key); + if (found == cells_.end() || found->second.state != State::Cleanup) return nextCleanup(); + found->second.ticket = ++nextTicket_; + return makeRequest(key); +} + +bool RuntimeGeneration::completeGeneration(ProcgenCellRequest* request, PointSet* output) { + if (!request || !output) return false; + const CellKey key{request->level_, request->x_, request->z_}; + const auto found = cells_.find(key); + if (found == cells_.end() || found->second.state != State::Generating || + request->seed_ != cellSeed(key) || request->ticket_ != found->second.ticket) + return false; + const int outputPoints = output->getCount(); + if ((maxPointsPerCell_ > 0 && outputPoints > maxPointsPerCell_) || + (maxResidentPoints_ > 0 && getResidentPointCount() > maxResidentPoints_ - outputPoints)) { + ++rejectedOutputCount_; + if (maxResidentPoints_ > 0 && outputPoints <= maxResidentPoints_) + trimToResidentPoints(maxResidentPoints_ - outputPoints); + return false; + } + found->second.output = *output; + found->second.failures = 0; + ++found->second.revision; + found->second.state = State::Active; + return true; +} + +bool RuntimeGeneration::failGeneration(ProcgenCellRequest* request) { + if (!request) return false; + const CellKey key{request->level_, request->x_, request->z_}; + const auto found = cells_.find(key); + if (found == cells_.end() || found->second.state != State::Generating || + request->ticket_ != found->second.ticket) + return false; + ++found->second.failures; + found->second.ticket = ++nextTicket_; + if (found->second.failures <= maxGenerationRetries_) { + found->second.state = State::Pending; + generateQueue_.push_back(key); + sortQueues(); + } else { + found->second.state = State::Failed; + } + return true; +} + +bool RuntimeGeneration::completeCleanup(ProcgenCellRequest* request) { + if (!request) return false; + const CellKey key{request->level_, request->x_, request->z_}; + const auto found = cells_.find(key); + if (found == cells_.end() || found->second.state != State::Cleanup || + request->ticket_ != found->second.ticket) + return false; + cells_.erase(found); + return true; +} + +bool RuntimeGeneration::hasCell(int level, int x, int z) const { + const auto found = cells_.find({level, x, z}); + return found != cells_.end() && found->second.state == State::Active; +} + +PointSet* RuntimeGeneration::getCellOutput(int level, int x, int z) const { + const auto found = cells_.find({level, x, z}); + return found != cells_.end() && found->second.state == State::Active + ? new PointSet(found->second.output) + : nullptr; +} + +uint64_t RuntimeGeneration::getCellRevision(int level, int x, int z) const { + const auto found = cells_.find({level, x, z}); + return found == cells_.end() ? 0 : found->second.revision; +} + +std::string RuntimeGeneration::debugReport() const { + std::ostringstream out; + out << "levels=" << levels_.size() << " cells=" << cells_.size() + << " pending=" << generateQueue_.size() << " generating=" << getGeneratingCount() + << " active=" << getActiveCellCount() << " cleanup=" << cleanupQueue_.size() + << " failed=" << getFailedCellCount() << " maxActive=" << maxActiveCells_ + << " residentPoints=" << getResidentPointCount() + << " maxResidentPoints=" << maxResidentPoints_ + << " maxPointsPerCell=" << maxPointsPerCell_ + << " rejectedOutputs=" << rejectedOutputCount_; + return out.str(); +} + +size_t RuntimeGeneration::CellKeyHash::operator()(const CellKey& key) const { + size_t hash = size_t(uint32_t(key.level)); + hash ^= size_t(uint32_t(key.x)) * 0x9e3779b1u + (hash << 6u) + (hash >> 2u); + hash ^= size_t(uint32_t(key.z)) * 0x85ebca77u + (hash << 6u) + (hash >> 2u); + return hash; +} + +ProcgenCellRequest* RuntimeGeneration::makeRequest(const CellKey& key) const { + auto* request = new ProcgenCellRequest(); + request->level_ = key.level; + request->x_ = key.x; + request->z_ = key.z; + request->seed_ = cellSeed(key); + const auto found = cells_.find(key); + request->ticket_ = found == cells_.end() ? 0 : found->second.ticket; + request->cellSize_ = levels_[size_t(key.level)].cellSize; + return request; +} + +uint32_t RuntimeGeneration::cellSeed(const CellKey& key) const { + return deriveSeed(worldSeed_, "cell:" + std::to_string(key.level) + ":" + + std::to_string(key.x) + ":" + std::to_string(key.z)); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/RuntimeGeneration.h b/src/modules/procgen/RuntimeGeneration.h new file mode 100644 index 00000000..b2cfd292 --- /dev/null +++ b/src/modules/procgen/RuntimeGeneration.h @@ -0,0 +1,213 @@ +#pragma once + +#include "procgen/PointSet.h" + +#include +#include +#include +#include + +namespace eve::procgen { + +/** @brief One immutable runtime-generation cell request returned to script. */ +class ProcgenCellRequest { +public: + int getLevel() const; + int getX() const; + int getZ() const; + uint32_t getSeed() const; + /** @brief Unique scheduler ticket used to reject stale asynchronous completions. */ + uint64_t getTicket() const; + float getMinX() const; + float getMinZ() const; + float getMaxX() const; + float getMaxZ() const; + +private: + friend class RuntimeGeneration; + int level_ = 0; + int x_ = 0; + int z_ = 0; + uint32_t seed_ = 1; + uint64_t ticket_ = 0; + float cellSize_ = 1.f; +}; + +/** + * @brief View-driven partition and hierarchical procedural generation scheduler. + * + * updateSource computes desired cells for every configured level. nextGenerate + * and nextCleanup provide bounded work queues; completeGeneration atomically + * publishes a cell output. The scheduler owns no scene or graphics objects. + */ +class RuntimeGeneration { +public: + /** @brief Create a scheduler with a stable world seed. */ + explicit RuntimeGeneration(uint32_t worldSeed = 1); + + /** @brief Remove levels, cells, queues and outputs while retaining the world seed. */ + void clear(); + /** + * @brief Add a hierarchical grid level. + * @param cellSize World-space XZ cell size. + * @param generationRadius Distance around the source that requests generation. + * @param cleanupMultiplier Cleanup radius divided by generation radius; must be at least 1. + * @return Level index, or -1 for invalid settings. + */ + int addLevel(float cellSize, float generationRadius, float cleanupMultiplier); + int getLevelCount() const; + float getLevelCellSize(int level) const; + float getLevelGenerationRadius(int level) const; + float getLevelCleanupRadius(int level) const; + + /** @brief Weight favoring cells in front of the source, clamped to [0,1]. */ + void setDirectionWeight(float weight); + float getDirectionWeight() const; + /** @brief Maximum simultaneously issued generation requests. */ + void setMaxGenerating(int count); + int getMaxGenerating() const; + /** @brief Maximum active plus in-flight cells; zero disables the resident-cell limit. */ + void setMaxActiveCells(int count); + /** @brief Return the resident-cell limit, or zero when unlimited. */ + int getMaxActiveCells() const; + /** @brief Set the maximum points allowed in one generated cell; zero disables the limit. */ + void setMaxPointsPerCell(int count); + /** @brief Return the per-cell point limit, or zero when unlimited. */ + int getMaxPointsPerCell() const; + /** @brief Set the total point limit across active cell outputs; zero disables the limit. */ + void setMaxResidentPoints(int count); + /** @brief Return the total resident-point limit, or zero when unlimited. */ + int getMaxResidentPoints() const; + /** @brief Return points retained by active or not-yet-acknowledged cleanup cells. */ + int getResidentPointCount() const; + /** @brief Return generation or restore outputs rejected by point budgets. */ + int getRejectedOutputCount() const; + /** + * @brief Schedule lowest-priority active cells for cleanup until at most target points remain. + * @return Number of cells newly scheduled for cleanup. + */ + int trimToResidentPoints(int target); + /** @brief Set retries after the initial generation attempt; zero fails immediately. */ + void setMaxGenerationRetries(int count); + /** @brief Return retries allowed after the initial generation attempt. */ + int getMaxGenerationRetries() const; + /** @brief CPU issue budget in milliseconds for one frame; zero disables the limit. */ + void setFrameTimeBudget(float milliseconds); + float getFrameTimeBudget() const; + /** @brief Start a new budget window before consuming generation requests. */ + void beginFrame(); + + /** + * @brief Recompute desired cells for a generation source. + * @param x,z World position. + * @param directionX,directionZ Horizontal view direction; zero disables direction priority. + */ + void updateSource(float x, float z, float directionX, float directionZ); + /** @brief Add or update a named generation source, then rebuild desired cells. */ + bool setGenerationSource(const std::string& id, float x, float z, float directionX, + float directionZ, float radiusScale = 1.f); + bool removeGenerationSource(const std::string& id); + void clearGenerationSources(); + int getGenerationSourceCount() const; + std::string getGenerationSourceId(int index) const; + /** @brief Re-evaluate generation/cleanup queues from every registered source. */ + void refreshGenerationSources(); + + /** + * @brief Enable view-cone scheduling. + * @param halfAngleDegrees Horizontal cone half angle in [1,180]. + * @param behindRadius Cells nearer than this distance generate even outside the cone. + */ + void setFrustumCulling(bool enabled, float halfAngleDegrees, float behindRadius); + bool isFrustumCullingEnabled() const; + float getFrustumHalfAngle() const; + float getFrustumBehindRadius() const; + + int getPendingGenerateCount() const; + int getGeneratingCount() const; + int getActiveCellCount() const; + int getPendingCleanupCount() const; + /** @brief Return cells stopped after exhausting the generation retry policy. */ + int getFailedCellCount() const; + /** @brief Reset and requeue all terminally failed cells still tracked by the scheduler. */ + int retryFailedCells(); + ProcgenCellRequest* nextGenerate(); + ProcgenCellRequest* nextCleanup(); + /** @brief Publish generated points for an issued request. */ + bool completeGeneration(ProcgenCellRequest* request, PointSet* output); + /** @brief Return an issued request to the pending queue after failure or cancellation. */ + bool failGeneration(ProcgenCellRequest* request); + /** @brief Acknowledge cleanup after consumers have removed spawned content. */ + bool completeCleanup(ProcgenCellRequest* request); + + bool hasCell(int level, int x, int z) const; + PointSet* getCellOutput(int level, int x, int z) const; + uint64_t getCellRevision(int level, int x, int z) const; + /** @brief Serialize one active cell cache entry in a deterministic versioned format. */ + std::string serializeCell(int level, int x, int z) const; + /** @brief Atomically restore one cell produced by serializeCell for this world seed. */ + bool deserializeCell(const std::string& definition); + std::string debugReport() const; + +private: + struct Level { + float cellSize = 1.f; + float generationRadius = 1.f; + float cleanupRadius = 1.f; + }; + enum class State { Pending, Generating, Active, Cleanup, Failed }; + struct CellKey { + int level = 0; + int x = 0; + int z = 0; + bool operator==(const CellKey& other) const { + return level == other.level && x == other.x && z == other.z; + } + }; + struct CellKeyHash { + size_t operator()(const CellKey& key) const; + }; + struct Cell { + State state = State::Pending; + float priority = 0.f; + uint64_t revision = 0; + uint64_t ticket = 0; + int failures = 0; + bool trimmed = false; + PointSet output; + }; + struct Source { + float x = 0.f; + float z = 0.f; + float directionX = 0.f; + float directionZ = 0.f; + float radiusScale = 1.f; + }; + + ProcgenCellRequest* makeRequest(const CellKey& key) const; + uint32_t cellSeed(const CellKey& key) const; + void sortQueues(); + + uint32_t worldSeed_ = 1; + float directionWeight_ = 0.25f; + int maxGenerating_ = 4; + int maxActiveCells_ = 0; + int maxPointsPerCell_ = 0; + int maxResidentPoints_ = 0; + int rejectedOutputCount_ = 0; + int maxGenerationRetries_ = 3; + float frameTimeBudgetMs_ = 0.f; + uint64_t frameStartedNs_ = 0; + bool frustumCulling_ = false; + float frustumHalfAngle_ = 60.f; + float frustumBehindRadius_ = 0.f; + std::vector levels_; + std::unordered_map sources_; + std::vector sourceOrder_; + std::unordered_map cells_; + std::vector generateQueue_; + std::vector cleanupQueue_; + uint64_t nextTicket_ = 0; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/RuntimeGenerationAsset.cpp b/src/modules/procgen/RuntimeGenerationAsset.cpp new file mode 100644 index 00000000..bfee3610 --- /dev/null +++ b/src/modules/procgen/RuntimeGenerationAsset.cpp @@ -0,0 +1,140 @@ +#include "procgen/RuntimeGeneration.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen { + +std::string RuntimeGeneration::serializeCell(int level, int x, int z) const { + const auto found = cells_.find({level, x, z}); + if (found == cells_.end() || found->second.state != State::Active) return {}; + std::ostringstream out; + out << std::setprecision(std::numeric_limits::max_digits10); + out << "EVPCG_CELL 1 " << worldSeed_ << ' ' << level << ' ' << x << ' ' << z << ' ' + << found->second.revision << ' ' << levels_[size_t(level)].cellSize << '\n'; + out << "POINTS " << found->second.output.getCount() << '\n'; + const auto& points = found->second.output.points(); + for (size_t index = 0; index < points.size(); ++index) { + const auto& point = points[index]; + out << "POINT " << point.x << ' ' << point.y << ' ' << point.z << ' ' << point.normalX + << ' ' << point.normalY << ' ' << point.normalZ << ' ' << point.yaw << ' ' + << point.scaleX << ' ' << point.scaleY << ' ' << point.scaleZ << ' ' << point.density + << ' ' << point.seed << '\n'; + std::vector names; + names.reserve(point.floatAttributes.size()); + for (const auto& [name, value] : point.floatAttributes) names.push_back(name); + std::sort(names.begin(), names.end()); + for (const auto& name : names) + out << "FLOAT " << index << ' ' << std::quoted(name) << ' ' + << point.floatAttributes.at(name) << '\n'; + names.clear(); + names.reserve(point.stringAttributes.size()); + for (const auto& [name, value] : point.stringAttributes) names.push_back(name); + std::sort(names.begin(), names.end()); + for (const auto& name : names) + out << "STRING " << index << ' ' << std::quoted(name) << ' ' + << std::quoted(point.stringAttributes.at(name)) << '\n'; + } + out << "END\n"; + return out.str(); +} + +bool RuntimeGeneration::deserializeCell(const std::string& definition) { + constexpr int maxPersistedPoints = 10000000; + std::istringstream input(definition); + std::string magic; + int version = 0; + uint32_t seed = 0; + CellKey key; + uint64_t revision = 0; + float cellSize = 0.f; + if (!(input >> magic >> version >> seed >> key.level >> key.x >> key.z >> revision >> + cellSize) || + magic != "EVPCG_CELL" || version != 1 || seed != worldSeed_ || revision == 0 || + key.level < 0 || key.level >= int(levels_.size()) || !std::isfinite(cellSize) || + cellSize != levels_[size_t(key.level)].cellSize) + return false; + std::string record; + int pointCount = 0; + if (!(input >> record >> pointCount) || record != "POINTS" || pointCount < 0 || + pointCount > maxPersistedPoints) + return false; + + PointSet restored; + for (int index = 0; index < pointCount; ++index) { + ProcgenPoint point; + if (!(input >> record) || record != "POINT" || + !(input >> point.x >> point.y >> point.z >> point.normalX >> point.normalY >> + point.normalZ >> point.yaw >> point.scaleX >> point.scaleY >> point.scaleZ >> + point.density >> point.seed)) + return false; + if (!std::isfinite(point.x) || !std::isfinite(point.y) || !std::isfinite(point.z) || + !std::isfinite(point.normalX) || !std::isfinite(point.normalY) || + !std::isfinite(point.normalZ) || !std::isfinite(point.yaw) || + !std::isfinite(point.scaleX) || !std::isfinite(point.scaleY) || + !std::isfinite(point.scaleZ) || !std::isfinite(point.density)) + return false; + restored.points().push_back(std::move(point)); + while (true) { + const std::streampos position = input.tellg(); + if (!(input >> record)) return false; + if (record == "FLOAT") { + int target = -1; + std::string name; + float value = 0.f; + if (!(input >> target >> std::quoted(name) >> value) || target != index || + name.empty() || !std::isfinite(value)) + return false; + restored.points().back().floatAttributes[name] = value; + } else if (record == "STRING") { + int target = -1; + std::string name; + std::string value; + if (!(input >> target >> std::quoted(name) >> std::quoted(value)) || + target != index || name.empty()) + return false; + restored.points().back().stringAttributes[name] = value; + } else { + input.seekg(position); + break; + } + } + } + if (!(input >> record) || record != "END") return false; + std::string trailing; + if (input >> trailing) return false; + + const auto existing = cells_.find(key); + const bool alreadyResident = + existing != cells_.end() && + (existing->second.state == State::Active || existing->second.state == State::Generating); + if (!alreadyResident && maxActiveCells_ > 0 && + getActiveCellCount() + getGeneratingCount() >= maxActiveCells_) + return false; + const int restoredPoints = restored.getCount(); + const int replacedPoints = + existing != cells_.end() ? existing->second.output.getCount() : 0; + if ((maxPointsPerCell_ > 0 && restoredPoints > maxPointsPerCell_) || + (maxResidentPoints_ > 0 && + getResidentPointCount() - replacedPoints > maxResidentPoints_ - restoredPoints)) { + ++rejectedOutputCount_; + return false; + } + generateQueue_.erase(std::remove(generateQueue_.begin(), generateQueue_.end(), key), + generateQueue_.end()); + cleanupQueue_.erase(std::remove(cleanupQueue_.begin(), cleanupQueue_.end(), key), + cleanupQueue_.end()); + Cell& cell = cells_[key]; + cell.state = State::Active; + cell.revision = revision; + cell.ticket = ++nextTicket_; + cell.failures = 0; + cell.trimmed = false; + cell.output = std::move(restored); + return true; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/ShapeGrammar.cpp b/src/modules/procgen/ShapeGrammar.cpp new file mode 100644 index 00000000..a94df6f3 --- /dev/null +++ b/src/modules/procgen/ShapeGrammar.cpp @@ -0,0 +1,333 @@ +#include "procgen/ShapeGrammar.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +uint32_t mixGrammar(uint32_t value) { + value += 0x9e3779b9u; + value = (value ^ (value >> 16u)) * 0x21f0aaadu; + value = (value ^ (value >> 15u)) * 0x735a2d97u; + return value ^ (value >> 15u); +} +float grammarUnit(uint32_t seed) { return float(mixGrammar(seed) >> 8u) * (1.f / 16777216.f); } + +float polylineLength(const PointSet& points) { + float result = 0.f; + for (size_t i = 1; i < points.points().size(); ++i) { + const auto& a = points.points()[i - 1]; + const auto& b = points.points()[i]; + const float dx = b.x - a.x; + const float dy = b.y - a.y; + const float dz = b.z - a.z; + result += std::sqrt(dx * dx + dy * dy + dz * dz); + } + return result; +} + +bool samplePolyline(const PointSet& points, float distance, ProcgenPoint& output) { + float cursor = 0.f; + for (size_t i = 1; i < points.points().size(); ++i) { + const auto& a = points.points()[i - 1]; + const auto& b = points.points()[i]; + const float dx = b.x - a.x; + const float dy = b.y - a.y; + const float dz = b.z - a.z; + const float length = std::sqrt(dx * dx + dy * dy + dz * dz); + if (length <= 0.f) continue; + if (distance <= cursor + length || i + 1 == points.points().size()) { + const float t = std::clamp((distance - cursor) / length, 0.f, 1.f); + output.x = a.x + dx * t; + output.y = a.y + dy * t; + output.z = a.z + dz * t; + output.yaw = std::atan2(dz, dx) * 57.29577951308232f; + return true; + } + cursor += length; + } + return false; +} + +} // namespace + +void ShapeGrammar::clear() { + modules_.clear(); + moduleOrder_.clear(); + error_.clear(); + lastSymbolCount_ = 0; + lastUsedLength_ = 0.f; + lastSplineLength_ = 0.f; +} + +bool ShapeGrammar::addModule(const std::string& symbol, const std::string& asset, float length, + float weight) { + if (symbol.empty() || asset.empty() || length <= 0.f || weight <= 0.f) return false; + const auto existing = modules_.find(symbol); + if (existing != modules_.end() && !existing->second.empty() && + std::abs(existing->second.front().length - length) > 0.0001f) + return false; + if (modules_.find(symbol) == modules_.end()) moduleOrder_.push_back(symbol); + modules_[symbol].push_back({asset, length, weight}); + return true; +} + +bool ShapeGrammar::removeModule(const std::string& symbol) { + if (modules_.erase(symbol) == 0) return false; + moduleOrder_.erase(std::remove(moduleOrder_.begin(), moduleOrder_.end(), symbol), + moduleOrder_.end()); + return true; +} +bool ShapeGrammar::hasModule(const std::string& symbol) const { + return modules_.find(symbol) != modules_.end(); +} +int ShapeGrammar::getModuleCount() const { return int(moduleOrder_.size()); } +std::string ShapeGrammar::getModuleSymbol(int index) const { + return index >= 0 && index < int(moduleOrder_.size()) ? moduleOrder_[size_t(index)] + : std::string(); +} +int ShapeGrammar::getVariantCount(const std::string& symbol) const { + const auto found = modules_.find(symbol); + return found == modules_.end() ? 0 : int(found->second.size()); +} +std::string ShapeGrammar::getVariantAsset(const std::string& symbol, int index) const { + const auto found = modules_.find(symbol); + return found != modules_.end() && index >= 0 && index < int(found->second.size()) + ? found->second[size_t(index)].asset + : std::string(); +} +float ShapeGrammar::getVariantLength(const std::string& symbol, int index) const { + const auto found = modules_.find(symbol); + return found != modules_.end() && index >= 0 && index < int(found->second.size()) + ? found->second[size_t(index)].length + : 0.f; +} + +bool ShapeGrammar::validate(const std::string& grammar) { + error_.clear(); + Parser parser{grammar}; + const auto parsed = parser.sequence(); + if (!parser.error.empty()) { + error_ = parser.error; + return false; + } + if (parsed.empty()) { + error_ = "grammar is empty"; + return false; + } + std::vector stack; + for (const auto& element : parsed) stack.push_back(&element); + while (!stack.empty()) { + const Element* element = stack.back(); + stack.pop_back(); + if (!element->symbol.empty() && !hasModule(element->symbol)) { + error_ = "unknown module: " + element->symbol; + return false; + } + for (const auto& child : element->children) stack.push_back(&child); + } + return true; +} + +PointSet* ShapeGrammar::generate(const std::string& grammar, PointSet* controlPoints, + uint32_t seed, bool acceptIncomplete) { + error_.clear(); + lastSymbolCount_ = 0; + lastUsedLength_ = 0.f; + lastSplineLength_ = 0.f; + if (!controlPoints || controlPoints->getCount() < 2) { + error_ = "generate: requires at least two control points"; + return nullptr; + } + if (!validate(grammar)) return nullptr; + Parser parser{grammar}; + const auto parsed = parser.sequence(); + lastSplineLength_ = polylineLength(*controlPoints); + if (!acceptIncomplete && sequenceMinLength(parsed) > lastSplineLength_ + 0.0001f) { + error_ = "mandatory grammar does not fit spline"; + return nullptr; + } + std::vector symbols; + float used = 0.f; + if (!expandSequence(parsed, lastSplineLength_, symbols, used)) { + error_ = "grammar expansion failed"; + return nullptr; + } + + PointSet output; + float cursor = 0.f; + for (size_t i = 0; i < symbols.size(); ++i) { + const auto* variant = chooseVariant(symbols[i], mixGrammar(seed ^ uint32_t(i))); + if (!variant || cursor + variant->length > lastSplineLength_ + 0.0001f) { + if (!acceptIncomplete) { + error_ = "expanded module does not fit spline: " + symbols[i]; + return nullptr; + } + break; + } + ProcgenPoint point; + if (!samplePolyline(*controlPoints, cursor + variant->length * 0.5f, point)) break; + point.seed = mixGrammar(seed ^ uint32_t(i)); + point.stringAttributes["module"] = symbols[i]; + point.stringAttributes["asset"] = variant->asset; + point.floatAttributes["length"] = variant->length; + output.points().push_back(std::move(point)); + cursor += variant->length; + } + lastSymbolCount_ = output.getCount(); + lastUsedLength_ = cursor; + return new PointSet(std::move(output)); +} + +std::string ShapeGrammar::getError() const { return error_; } +std::string ShapeGrammar::debugReport() const { + std::ostringstream out; + out << "modules=" << modules_.size() << " symbols=" << lastSymbolCount_ + << " used=" << lastUsedLength_ << " spline=" << lastSplineLength_; + if (!error_.empty()) out << " error=" << error_; + return out.str(); +} + +void ShapeGrammar::Parser::skipWhitespace() { + while (position < text.size() && std::isspace(static_cast(text[position]))) + ++position; +} + +std::vector ShapeGrammar::Parser::sequence(char terminator) { + std::vector result; + while (position < text.size()) { + skipWhitespace(); + if (position >= text.size()) break; + if (terminator != '\0' && text[position] == terminator) { + ++position; + return result; + } + Element element; + if (text[position] == '[') { + ++position; + element.children = sequence(']'); + if (!error.empty()) return {}; + if (element.children.empty()) { + error = "empty grammar group"; + return {}; + } + } else { + const size_t start = position; + while (position < text.size()) { + const unsigned char ch = static_cast(text[position]); + // Decimal digits are reserved for the exact-repeat suffix: A3 means + // three A modules. Keeping module tokens digit-free removes the + // otherwise unresolvable ambiguity between a symbol and its repeat. + if (!std::isalpha(ch) && ch != '_' && ch != '-' && ch != '.') break; + ++position; + } + if (start == position) { + error = "expected module at character " + std::to_string(position); + return {}; + } + element.symbol = text.substr(start, position - start); + } + skipWhitespace(); + if (position < text.size() && text[position] == '*') { + element.repeatMin = 0; + element.repeatMax = -1; + ++position; + } else if (position < text.size() && text[position] == '+') { + element.repeatMin = 1; + element.repeatMax = -1; + ++position; + } else if (position < text.size() && std::isdigit(static_cast(text[position]))) { + const size_t start = position; + while (position < text.size() && + std::isdigit(static_cast(text[position]))) + ++position; + element.repeatMin = element.repeatMax = + std::max(0, std::atoi(text.substr(start, position - start).c_str())); + } + result.push_back(std::move(element)); + skipWhitespace(); + if (position < text.size() && text[position] == ',') { + ++position; + continue; + } + if (terminator != '\0' && position < text.size() && text[position] == terminator) continue; + if (position < text.size()) { + error = "expected comma at character " + std::to_string(position); + return {}; + } + } + if (terminator != '\0') error = "unterminated grammar group"; + return result; +} + +float ShapeGrammar::elementMinLength(const Element& element) const { + float unit = 0.f; + if (!element.symbol.empty()) { + const auto found = modules_.find(element.symbol); + if (found != modules_.end() && !found->second.empty()) { + unit = found->second.front().length; + for (const auto& variant : found->second) unit = std::min(unit, variant.length); + } + } else { + unit = sequenceMinLength(element.children); + } + return unit * float(element.repeatMin); +} + +float ShapeGrammar::sequenceMinLength(const std::vector& sequence, size_t from) const { + float result = 0.f; + for (size_t i = from; i < sequence.size(); ++i) result += elementMinLength(sequence[i]); + return result; +} + +bool ShapeGrammar::expandSequence(const std::vector& sequence, float available, + std::vector& symbols, float& used) const { + for (size_t i = 0; i < sequence.size(); ++i) { + const auto& element = sequence[i]; + float unitLength = !element.symbol.empty() ? elementMinLength(Element{element.symbol, {}, 1, 1}) + : sequenceMinLength(element.children); + if (unitLength <= 0.f) return false; + const float reservedAfter = sequenceMinLength(sequence, i + 1); + int repeats = element.repeatMin; + if (element.repeatMax < 0) { + const float room = std::max(0.f, available - used - reservedAfter); + repeats = std::max(element.repeatMin, int(std::floor(room / unitLength))); + } else { + repeats = element.repeatMax; + } + for (int repeat = 0; repeat < repeats; ++repeat) { + if (used + unitLength + reservedAfter > available + 0.0001f) return true; + if (!element.symbol.empty()) { + symbols.push_back(element.symbol); + used += unitLength; + } else { + float groupUsed = 0.f; + if (!expandSequence(element.children, available - used - reservedAfter, symbols, + groupUsed)) + return false; + used += groupUsed; + } + } + } + return true; +} + +const ShapeModuleVariant* ShapeGrammar::chooseVariant(const std::string& symbol, + uint32_t seed) const { + const auto found = modules_.find(symbol); + if (found == modules_.end() || found->second.empty()) return nullptr; + float total = 0.f; + for (const auto& variant : found->second) total += variant.weight; + float choice = grammarUnit(seed) * total; + for (const auto& variant : found->second) { + choice -= variant.weight; + if (choice <= 0.f) return &variant; + } + return &found->second.back(); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/ShapeGrammar.h b/src/modules/procgen/ShapeGrammar.h new file mode 100644 index 00000000..dd49f5dc --- /dev/null +++ b/src/modules/procgen/ShapeGrammar.h @@ -0,0 +1,83 @@ +#pragma once + +#include "procgen/PointSet.h" + +#include +#include +#include +#include + +namespace eve::procgen { + +/** @brief One weighted asset variant represented by a shape-grammar symbol. */ +struct ShapeModuleVariant { + std::string asset; + float length = 1.f; + float weight = 1.f; +}; + +/** + * @brief Module-based shape grammar expanded continuously along a 3D polyline. + * + * Grammar syntax follows UE PCG's useful subset: comma-separated symbols, + * bracket groups, `*` (zero or more), `+` (one or more), and an integer repeat + * suffix. Module symbols do not contain decimal digits, which are reserved for + * exact repetition. Repetitions fill available spline length without crossing its end. + */ +class ShapeGrammar { +public: + /** @brief Remove every registered symbol and diagnostic. */ + void clear(); + /** @brief Register a weighted asset variant for a symbol. */ + bool addModule(const std::string& symbol, const std::string& asset, float length, + float weight = 1.f); + bool removeModule(const std::string& symbol); + bool hasModule(const std::string& symbol) const; + int getModuleCount() const; + std::string getModuleSymbol(int index) const; + int getVariantCount(const std::string& symbol) const; + std::string getVariantAsset(const std::string& symbol, int index) const; + float getVariantLength(const std::string& symbol, int index) const; + + /** @brief Parse and validate grammar without generating output. */ + bool validate(const std::string& grammar); + /** + * @brief Expand grammar along control points. + * @param acceptIncomplete Keep a final module only when it fully fits; when false, + * generation fails if mandatory grammar cannot fit the spline. + */ + PointSet* generate(const std::string& grammar, PointSet* controlPoints, uint32_t seed, + bool acceptIncomplete); + std::string getError() const; + std::string debugReport() const; + +private: + struct Element { + std::string symbol; + std::vector children; + int repeatMin = 1; + int repeatMax = 1; // -1 = fill available length + }; + struct Parser { + const std::string& text; + size_t position = 0; + std::string error; + std::vector sequence(char terminator = '\0'); + void skipWhitespace(); + }; + + float elementMinLength(const Element& element) const; + float sequenceMinLength(const std::vector& sequence, size_t from = 0) const; + bool expandSequence(const std::vector& sequence, float available, + std::vector& symbols, float& used) const; + const ShapeModuleVariant* chooseVariant(const std::string& symbol, uint32_t seed) const; + + std::unordered_map> modules_; + std::vector moduleOrder_; + std::string error_; + int lastSymbolCount_ = 0; + float lastUsedLength_ = 0.f; + float lastSplineLength_ = 0.f; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/ShapeGrammarScript.cpp b/src/modules/procgen/ShapeGrammarScript.cpp new file mode 100644 index 00000000..55b94b6e --- /dev/null +++ b/src/modules/procgen/ShapeGrammarScript.cpp @@ -0,0 +1,30 @@ +#include "procgen/ShapeGrammarScript.h" + +#include "procgen/ShapeGrammar.h" + +#include + +#include + +namespace eve::procgen { + +void exposeShapeGrammar(ssq::Table& table) { + auto grammar = table.addClass( + "ProcgenShapeGrammar", + std::function([]() -> ShapeGrammar* { return nullptr; }), true); + grammar.addFunc("clear", &ShapeGrammar::clear); + grammar.addFunc("addModule", &ShapeGrammar::addModule); + grammar.addFunc("removeModule", &ShapeGrammar::removeModule); + grammar.addFunc("hasModule", &ShapeGrammar::hasModule); + grammar.addFunc("getModuleCount", &ShapeGrammar::getModuleCount); + grammar.addFunc("getModuleSymbol", &ShapeGrammar::getModuleSymbol); + grammar.addFunc("getVariantCount", &ShapeGrammar::getVariantCount); + grammar.addFunc("getVariantAsset", &ShapeGrammar::getVariantAsset); + grammar.addFunc("getVariantLength", &ShapeGrammar::getVariantLength); + grammar.addFunc("validate", &ShapeGrammar::validate); + grammar.addFunc("generate", &ShapeGrammar::generate); + grammar.addFunc("getError", &ShapeGrammar::getError); + grammar.addFunc("debugReport", &ShapeGrammar::debugReport); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/ShapeGrammarScript.h b/src/modules/procgen/ShapeGrammarScript.h new file mode 100644 index 00000000..3369ab98 --- /dev/null +++ b/src/modules/procgen/ShapeGrammarScript.h @@ -0,0 +1,12 @@ +#pragma once + +namespace ssq { +class Table; +} + +namespace eve::procgen { + +/** @brief Register shape-grammar script types on the Procgen module table. */ +void exposeShapeGrammar(ssq::Table& table); + +} // namespace eve::procgen diff --git a/src/modules/procgen/SpatialData.cpp b/src/modules/procgen/SpatialData.cpp new file mode 100644 index 00000000..ee3ab61c --- /dev/null +++ b/src/modules/procgen/SpatialData.cpp @@ -0,0 +1,302 @@ +#include "procgen/SpatialData.h" + +#include +#include +#include + +namespace eve::procgen { +namespace { + +uint32_t mixSpatialSeed(uint32_t value) { + value += 0x9e3779b9u; + value = (value ^ (value >> 16u)) * 0x21f0aaadu; + value = (value ^ (value >> 15u)) * 0x735a2d97u; + return value ^ (value >> 15u); +} + +float unitFloat(uint32_t seed) { return float(mixSpatialSeed(seed) >> 8u) * (1.f / 16777216.f); } + +SpatialBounds unionBounds(const SpatialBounds& a, const SpatialBounds& b) { + if (!a.valid) return b; + if (!b.valid) return a; + return {std::min(a.minX, b.minX), std::min(a.minY, b.minY), std::min(a.minZ, b.minZ), + std::max(a.maxX, b.maxX), std::max(a.maxY, b.maxY), std::max(a.maxZ, b.maxZ), true}; +} + +SpatialBounds intersectionBounds(const SpatialBounds& a, const SpatialBounds& b) { + if (!a.valid || !b.valid) return {}; + SpatialBounds result{std::max(a.minX, b.minX), std::max(a.minY, b.minY), + std::max(a.minZ, b.minZ), std::min(a.maxX, b.maxX), + std::min(a.maxY, b.maxY), std::min(a.maxZ, b.maxZ), true}; + if (result.minX > result.maxX || result.minY > result.maxY || result.minZ > result.maxZ) + return {}; + return result; +} + +float pointSegmentDistanceSquared(float px, float py, float pz, const ProcgenPoint& a, + const ProcgenPoint& b) { + const float vx = b.x - a.x; + const float vy = b.y - a.y; + const float vz = b.z - a.z; + const float l2 = vx * vx + vy * vy + vz * vz; + const float t = l2 > 0.f ? std::clamp(((px - a.x) * vx + (py - a.y) * vy + + (pz - a.z) * vz) / + l2, + 0.f, 1.f) + : 0.f; + const float dx = px - (a.x + vx * t); + const float dy = py - (a.y + vy * t); + const float dz = pz - (a.z + vz * t); + return dx * dx + dy * dy + dz * dz; +} + +} // namespace + +SpatialData SpatialData::fromPoints(const PointSet& points) { + SpatialData result; + if (points.empty()) return result; + result.kind_ = Kind::Points; + result.points_ = points; + auto& bounds = result.bounds_; + bounds.minX = bounds.minY = bounds.minZ = std::numeric_limits::max(); + bounds.maxX = bounds.maxY = bounds.maxZ = std::numeric_limits::lowest(); + for (const auto& point : points.points()) { + bounds.minX = std::min(bounds.minX, point.x); + bounds.minY = std::min(bounds.minY, point.y); + bounds.minZ = std::min(bounds.minZ, point.z); + bounds.maxX = std::max(bounds.maxX, point.x); + bounds.maxY = std::max(bounds.maxY, point.y); + bounds.maxZ = std::max(bounds.maxZ, point.z); + } + bounds.valid = true; + return result; +} + +SpatialData SpatialData::box(float minX, float minY, float minZ, float maxX, float maxY, + float maxZ) { + SpatialData result; + result.kind_ = Kind::Box; + result.bounds_ = {std::min(minX, maxX), std::min(minY, maxY), std::min(minZ, maxZ), + std::max(minX, maxX), std::max(minY, maxY), std::max(minZ, maxZ), true}; + return result; +} + +SpatialData SpatialData::sphere(float x, float y, float z, float radius) { + SpatialData result; + if (radius <= 0.f) return result; + result.kind_ = Kind::Sphere; + result.centerX_ = x; + result.centerY_ = y; + result.centerZ_ = z; + result.radius_ = radius; + result.bounds_ = {x - radius, y - radius, z - radius, x + radius, y + radius, z + radius, + true}; + return result; +} + +SpatialData SpatialData::spline(const PointSet& controlPoints, float radius) { + SpatialData result = fromPoints(controlPoints); + if (controlPoints.getCount() < 2 || radius < 0.f) return {}; + result.kind_ = Kind::Spline; + result.radius_ = radius; + result.bounds_.minX -= radius; + result.bounds_.minY -= radius; + result.bounds_.minZ -= radius; + result.bounds_.maxX += radius; + result.bounds_.maxY += radius; + result.bounds_.maxZ += radius; + return result; +} + +SpatialData SpatialData::heightfield(const Heightmap& heightmap, float originX, float originZ, + float cellSize, float heightScale) { + SpatialData result; + if (heightmap.getWidth() <= 0 || heightmap.getHeight() <= 0 || cellSize <= 0.f) return result; + result.kind_ = Kind::Heightfield; + result.heightmap_ = std::make_shared(heightmap); + result.originX_ = originX; + result.originZ_ = originZ; + result.cellSize_ = cellSize; + result.heightScale_ = heightScale; + float minHeight = std::numeric_limits::max(); + float maxHeight = std::numeric_limits::lowest(); + for (int z = 0; z < heightmap.getHeight(); ++z) { + for (int x = 0; x < heightmap.getWidth(); ++x) { + const float value = heightmap.height(x, z) * heightScale; + minHeight = std::min(minHeight, value); + maxHeight = std::max(maxHeight, value); + } + } + result.bounds_ = {originX, minHeight, originZ, + originX + float(heightmap.getWidth() - 1) * cellSize, maxHeight, + originZ + float(heightmap.getHeight() - 1) * cellSize, true}; + return result; +} + +SpatialData SpatialData::unite(const SpatialData& a, const SpatialData& b) { + SpatialData result; + result.kind_ = Kind::Union; + result.left_ = std::make_shared(a); + result.right_ = std::make_shared(b); + result.bounds_ = unionBounds(a.bounds(), b.bounds()); + return result; +} + +SpatialData SpatialData::intersect(const SpatialData& a, const SpatialData& b) { + SpatialData result; + result.kind_ = Kind::Intersection; + result.left_ = std::make_shared(a); + result.right_ = std::make_shared(b); + result.bounds_ = intersectionBounds(a.bounds(), b.bounds()); + return result; +} + +SpatialData SpatialData::subtract(const SpatialData& a, const SpatialData& b) { + SpatialData result; + result.kind_ = Kind::Difference; + result.left_ = std::make_shared(a); + result.right_ = std::make_shared(b); + result.bounds_ = a.bounds(); + return result; +} + +std::string SpatialData::getKind() const { + switch (kind_) { + case Kind::Points: return "points"; + case Kind::Box: return "volume.box"; + case Kind::Sphere: return "volume.sphere"; + case Kind::Spline: return "spline"; + case Kind::Heightfield: return "surface.heightfield"; + case Kind::Union: return "union"; + case Kind::Intersection: return "intersection"; + case Kind::Difference: return "difference"; + default: return "empty"; + } +} + +bool SpatialData::contains(float x, float y, float z) const { + switch (kind_) { + case Kind::Points: + for (const auto& point : points_.points()) + if (point.x == x && point.y == y && point.z == z) return true; + return false; + case Kind::Box: + return x >= bounds_.minX && x <= bounds_.maxX && y >= bounds_.minY && + y <= bounds_.maxY && z >= bounds_.minZ && z <= bounds_.maxZ; + case Kind::Sphere: { + const float dx = x - centerX_; + const float dy = y - centerY_; + const float dz = z - centerZ_; + return dx * dx + dy * dy + dz * dz <= radius_ * radius_; + } + case Kind::Spline: + for (size_t i = 1; i < points_.points().size(); ++i) + if (pointSegmentDistanceSquared(x, y, z, points_.points()[i - 1], + points_.points()[i]) <= radius_ * radius_) + return true; + return false; + case Kind::Heightfield: { + if (x < bounds_.minX || x > bounds_.maxX || z < bounds_.minZ || z > bounds_.maxZ) + return false; + const float hx = (x - originX_) / cellSize_; + const float hz = (z - originZ_) / cellSize_; + const float surface = heightmap_->sampleBilinear(hx, hz) * heightScale_; + return std::abs(y - surface) <= cellSize_ * 0.5f; + } + case Kind::Union: return left_->contains(x, y, z) || right_->contains(x, y, z); + case Kind::Intersection: return left_->contains(x, y, z) && right_->contains(x, y, z); + case Kind::Difference: return left_->contains(x, y, z) && !right_->contains(x, y, z); + default: return false; + } +} + +SpatialBounds SpatialData::bounds() const { return bounds_; } +bool SpatialData::hasBounds() const { return bounds_.valid; } +float SpatialData::getMinX() const { return bounds_.valid ? bounds_.minX : 0.f; } +float SpatialData::getMinY() const { return bounds_.valid ? bounds_.minY : 0.f; } +float SpatialData::getMinZ() const { return bounds_.valid ? bounds_.minZ : 0.f; } +float SpatialData::getMaxX() const { return bounds_.valid ? bounds_.maxX : 0.f; } +float SpatialData::getMaxY() const { return bounds_.valid ? bounds_.maxY : 0.f; } +float SpatialData::getMaxZ() const { return bounds_.valid ? bounds_.maxZ : 0.f; } + +PointSet SpatialData::sample(float spacing, uint32_t seed, float jitter) const { + PointSet output; + if (!bounds_.valid || spacing <= 0.f) return output; + jitter = std::clamp(jitter, 0.f, 1.f); + const float extent = spacing * jitter * 0.5f; + uint32_t index = 0; + if (kind_ == Kind::Heightfield) { + for (float z = bounds_.minZ; z <= bounds_.maxZ + spacing * 0.001f; z += spacing) { + for (float x = bounds_.minX; x <= bounds_.maxX + spacing * 0.001f; x += spacing) { + const uint32_t pointSeed = mixSpatialSeed(seed ^ index++); + const float sx = std::clamp(x + (unitFloat(pointSeed) * 2.f - 1.f) * extent, + bounds_.minX, bounds_.maxX); + const float sz = std::clamp( + z + (unitFloat(pointSeed ^ 0x02e5be93u) * 2.f - 1.f) * extent, + bounds_.minZ, bounds_.maxZ); + const int added = output.add(sx, 0.f, sz); + output.setPointSeed(added, pointSeed); + } + } + return project(output); + } + for (float z = bounds_.minZ; z <= bounds_.maxZ + spacing * 0.001f; z += spacing) { + for (float y = bounds_.minY; y <= bounds_.maxY + spacing * 0.001f; y += spacing) { + for (float x = bounds_.minX; x <= bounds_.maxX + spacing * 0.001f; x += spacing) { + const uint32_t pointSeed = mixSpatialSeed(seed ^ index++); + const float sx = bounds_.maxX > bounds_.minX + ? x + (unitFloat(pointSeed) * 2.f - 1.f) * extent + : x; + const float sy = bounds_.maxY > bounds_.minY + ? y + (unitFloat(pointSeed ^ 0x68bc21ebu) * 2.f - 1.f) * extent + : y; + const float sz = bounds_.maxZ > bounds_.minZ + ? z + (unitFloat(pointSeed ^ 0x02e5be93u) * 2.f - 1.f) * extent + : z; + if (!contains(sx, sy, sz)) continue; + const int added = output.add(sx, sy, sz); + output.setPointSeed(added, pointSeed); + } + } + } + return output; +} + +PointSet SpatialData::filter(const PointSet& input, bool invert) const { + PointSet output; + for (const auto& point : input.points()) + if (contains(point.x, point.y, point.z) != invert) output.points().push_back(point); + return output; +} + +PointSet SpatialData::project(const PointSet& input) const { + if (kind_ != Kind::Heightfield || !heightmap_) return input; + PointSet output = input; + for (auto& point : output.points()) { + if (point.x < bounds_.minX || point.x > bounds_.maxX || point.z < bounds_.minZ || + point.z > bounds_.maxZ) + continue; + const float hx = (point.x - originX_) / cellSize_; + const float hz = (point.z - originZ_) / cellSize_; + point.y = heightmap_->sampleBilinear(hx, hz) * heightScale_; + const float left = heightmap_->sampleBilinear(hx - 0.5f, hz) * heightScale_; + const float right = heightmap_->sampleBilinear(hx + 0.5f, hz) * heightScale_; + const float down = heightmap_->sampleBilinear(hx, hz - 0.5f) * heightScale_; + const float up = heightmap_->sampleBilinear(hx, hz + 0.5f) * heightScale_; + float nx = -(right - left) / cellSize_; + float ny = 1.f; + float nz = -(up - down) / cellSize_; + const float length = std::sqrt(nx * nx + ny * ny + nz * nz); + if (length > 0.f) { + nx /= length; + ny /= length; + nz /= length; + } + point.normalX = nx; + point.normalY = ny; + point.normalZ = nz; + } + return output; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/SpatialData.h b/src/modules/procgen/SpatialData.h new file mode 100644 index 00000000..6494fd25 --- /dev/null +++ b/src/modules/procgen/SpatialData.h @@ -0,0 +1,110 @@ +#pragma once + +#include "procgen/PointSet.h" +#include "procgen/heightmap/Heightmap.h" + +#include +#include +#include + +namespace eve::procgen { + +/** @brief Axis-aligned bounds shared by all procedural spatial data types. */ +struct SpatialBounds { + float minX = 0.f; + float minY = 0.f; + float minZ = 0.f; + float maxX = 0.f; + float maxY = 0.f; + float maxZ = 0.f; + bool valid = false; +}; + +/** + * @brief Queryable spatial input for procedural pipelines. + * + * SpatialData mirrors the data-oriented role of UE PCG spatial data without + * coupling the procgen module to scene or rendering types. Values are cheap to + * copy and composite operations retain immutable copies of their operands. + */ +class SpatialData { +public: + enum class Kind { + Empty, + Points, + Box, + Sphere, + Spline, + Heightfield, + Union, + Intersection, + Difference + }; + + /** @brief Construct empty spatial data. */ + SpatialData() = default; + + /** @brief Create spatial data whose domain is the bounds of attributed points. */ + static SpatialData fromPoints(const PointSet& points); + /** @brief Create an axis-aligned volume; unordered endpoints are normalized. */ + static SpatialData box(float minX, float minY, float minZ, float maxX, float maxY, + float maxZ); + /** @brief Create a spherical volume. A non-positive radius produces an empty domain. */ + static SpatialData sphere(float x, float y, float z, float radius); + /** @brief Create a polyline domain with a radial influence width. */ + static SpatialData spline(const PointSet& controlPoints, float radius); + /** @brief Create a sampled heightfield surface in world space. */ + static SpatialData heightfield(const Heightmap& heightmap, float originX, float originZ, + float cellSize, float heightScale); + /** @brief Create the union of two domains. */ + static SpatialData unite(const SpatialData& a, const SpatialData& b); + /** @brief Create the intersection of two domains. */ + static SpatialData intersect(const SpatialData& a, const SpatialData& b); + /** @brief Create the part of a outside b. */ + static SpatialData subtract(const SpatialData& a, const SpatialData& b); + + /** @brief Stable textual kind used by scripts and diagnostics. */ + std::string getKind() const; + /** @brief Whether this domain contains a world-space position. */ + bool contains(float x, float y, float z) const; + /** @brief Conservative world-space bounds, or invalid bounds for an empty domain. */ + SpatialBounds bounds() const; + bool hasBounds() const; + float getMinX() const; + float getMinY() const; + float getMinZ() const; + float getMaxX() const; + float getMaxY() const; + float getMaxZ() const; + + /** + * @brief Deterministically sample a 3D lattice inside the domain. + * @param spacing Distance between candidates on all axes. + * @param seed Root seed used for stable point seeds and jitter. + * @param jitter Fraction of spacing used for position jitter, clamped to [0,1]. + * @return Attributed samples inside the domain. + */ + PointSet sample(float spacing, uint32_t seed, float jitter) const; + /** @brief Filter attributed points by this domain. */ + PointSet filter(const PointSet& input, bool invert = false) const; + /** @brief Project points onto a heightfield surface; other kinds return an unchanged copy. */ + PointSet project(const PointSet& input) const; + +private: + Kind kind_ = Kind::Empty; + SpatialBounds bounds_; + PointSet points_; + float centerX_ = 0.f; + float centerY_ = 0.f; + float centerZ_ = 0.f; + float radius_ = 0.f; + std::shared_ptr heightmap_; + float originX_ = 0.f; + float originZ_ = 0.f; + float cellSize_ = 1.f; + float heightScale_ = 1.f; + std::shared_ptr left_; + std::shared_ptr right_; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/algorithms/LSystem.cpp b/src/modules/procgen/algorithms/LSystem.cpp new file mode 100644 index 00000000..c23af2c6 --- /dev/null +++ b/src/modules/procgen/algorithms/LSystem.cpp @@ -0,0 +1,247 @@ +#include "procgen/algorithms/LSystem.h" +#include "procgen/PointSet.h" + +#include +#include +#include + +namespace eve::procgen { + +namespace { + +constexpr float kPi = 3.14159265358979323846f; + +float radians(float degrees) { return degrees * (kPi / 180.f); } + +float clampUnit(float value) { return std::max(0.f, std::min(1.f, value)); } + +} // namespace + +LSystem::LSystem() { + std::fill(std::begin(leafChars_), std::end(leafChars_), false); +} + +void LSystem::setAxiom(const std::string& axiom) { axiom_ = axiom; } + +void LSystem::addRule(char symbol, const std::string& production) { + rules_[static_cast(symbol)] = {Rule{production, 1.f}}; +} + +void LSystem::addRules(char symbol, const std::vector& productions, + const std::vector& weights) { + auto& target = rules_[static_cast(symbol)]; + const size_t count = std::min(productions.size(), weights.size()); + target.clear(); + for (size_t i = 0; i < count; ++i) { + const float w = i < weights.size() ? std::max(0.f, weights[i]) : 1.f; + target.emplace_back(productions[i], w); + } +} + +void LSystem::clearRules() { + for (auto& slot : rules_) slot.clear(); +} + +void LSystem::setAngle(float degrees) { angleDeg_ = degrees; } +void LSystem::setStep(float step) { step_ = std::max(0.f, step); } +void LSystem::setIterations(int iterations) { iterations_ = std::max(0, iterations); } +void LSystem::setSeed(uint32_t seed) { seed_ = seed; } + +void LSystem::setInitialHeading(float x, float y, float z) { + const float n = std::sqrt(x * x + y * y + z * z); + if (n <= 1e-9f) return; + heading0_ = {x / n, y / n, z / n}; +} + +void LSystem::setBranchRadius(float radius) { branchRadius_ = std::max(0.f, radius); } +void LSystem::setBranchRadiusFalloff(float factor) { radiusFalloff_ = clampUnit(factor); } +void LSystem::setLeafSize(float size) { leafSize_ = std::max(0.f, size); } + +void LSystem::setLeafSymbols(const std::string& symbols) { + std::fill(std::begin(leafChars_), std::end(leafChars_), false); + for (char c : symbols) leafChars_[static_cast(c)] = true; +} + +void LSystem::setTropism(float x, float y, float z) { tropism_ = {x, y, z}; } + +uint32_t LSystem::getSeed() const { return seed_; } +int LSystem::getIterations() const { return iterations_; } + +LSystem::Vec3 LSystem::rotate(const Vec3& v, const Vec3& axis, float angle) const { + const float c = std::cos(angle); + const float s = std::sin(angle); + const float dot = v.x * axis.x + v.y * axis.y + v.z * axis.z; + Vec3 cross{axis.y * v.z - axis.z * v.y, axis.z * v.x - axis.x * v.z, + axis.x * v.y - axis.y * v.x}; + return {v.x * c + cross.x * s + axis.x * dot * (1.f - c), + v.y * c + cross.y * s + axis.y * dot * (1.f - c), + v.z * c + cross.z * s + axis.z * dot * (1.f - c)}; +} + +void LSystem::orthonormalize(Vec3& heading, Vec3& up) const { + // right = normalize(cross(up, heading)); then re-derive up to stay exact. + Vec3 right{up.y * heading.z - up.z * heading.y, up.z * heading.x - up.x * heading.z, + up.x * heading.y - up.y * heading.x}; + float n = std::sqrt(right.x * right.x + right.y * right.y + right.z * right.z); + if (n <= 1e-9f) return; + right = {right.x / n, right.y / n, right.z / n}; + // up = normalize(cross(heading, right)) + Vec3 nextUp{heading.y * right.z - heading.z * right.y, + heading.z * right.x - heading.x * right.z, + heading.x * right.y - heading.y * right.x}; + n = std::sqrt(nextUp.x * nextUp.x + nextUp.y * nextUp.y + nextUp.z * nextUp.z); + if (n > 1e-9f) up = {nextUp.x / n, nextUp.y / n, nextUp.z / n}; +} + +std::string LSystem::derive() const { + std::string word = axiom_; + std::mt19937 rng(seed_); + std::uniform_real_distribution unit(0.f, 1.f); + for (int iter = 0; iter < iterations_; ++iter) { + std::string next; + next.reserve(word.size() * 2 + 16); + for (char ch : word) { + const std::vector& ruleSet = rules_[static_cast(ch)]; + if (ruleSet.empty()) { // no productions: keep the terminal symbol + next.push_back(ch); + continue; + } + if (ruleSet.size() == 1) { + next += ruleSet[0].first; + continue; + } + float total = 0.f; + for (const Rule& r : ruleSet) total += r.second; + if (total <= 0.f) { + next += ruleSet[0].first; + continue; + } + const float pick = total * unit(rng); + float acc = 0.f; + const std::string* chosen = &ruleSet[0].first; + for (const Rule& r : ruleSet) { + acc += r.second; + if (pick < acc) { + chosen = &r.first; + break; + } + } + next += *chosen; + } + word.swap(next); + } + return word; +} + +void LSystem::interpret(const std::string& word, LSystemResult& out) const { + const float angle = radians(angleDeg_); + TurtleState t; + t.pos = {0.f, 0.f, 0.f}; + t.heading = heading0_; + t.up = {0.f, 0.f, 1.f}; + t.depth = 0; + orthonormalize(t.heading, t.up); + + std::vector stack; + + for (char ch : word) { + switch (ch) { + case 'F': { + // Slight phototropism bias toward the tropism direction. + if (tropism_.x != 0.f || tropism_.y != 0.f || tropism_.z != 0.f) { + t.heading = {t.heading.x + tropism_.x * 0.02f, + t.heading.y + tropism_.y * 0.02f, + t.heading.z + tropism_.z * 0.02f}; + const float n = std::sqrt(t.heading.x * t.heading.x + + t.heading.y * t.heading.y + t.heading.z * t.heading.z); + if (n > 1e-9f) t.heading = {t.heading.x / n, t.heading.y / n, t.heading.z / n}; + orthonormalize(t.heading, t.up); + } + const Vec3 start = t.pos; + const float scale = std::pow(radiusFalloff_, float(t.depth)); + const float r0 = branchRadius_ * scale; + t.pos = {t.pos.x + t.heading.x * step_, t.pos.y + t.heading.y * step_, + t.pos.z + t.heading.z * step_}; + const float r1 = branchRadius_ * std::pow(radiusFalloff_, float(t.depth + 1)); + LSystemSegment seg; + seg.sx = start.x; seg.sy = start.y; seg.sz = start.z; + seg.ex = t.pos.x; seg.ey = t.pos.y; seg.ez = t.pos.z; + seg.r0 = r0; seg.r1 = r1; seg.depth = t.depth; + out.segments.push_back(seg); + break; + } + case 'f': + t.pos = {t.pos.x + t.heading.x * step_, t.pos.y + t.heading.y * step_, + t.pos.z + t.heading.z * step_}; + break; + case '+': t.heading = rotate(t.heading, t.up, angle); orthonormalize(t.heading, t.up); break; + case '-': t.heading = rotate(t.heading, t.up, -angle); orthonormalize(t.heading, t.up); break; + case '&': { + Vec3 right{0.f, 0.f, 0.f}; + orthonormalize(t.heading, t.up); + right = {t.up.y * t.heading.z - t.up.z * t.heading.y, + t.up.z * t.heading.x - t.up.x * t.heading.z, + t.up.x * t.heading.y - t.up.y * t.heading.x}; + const float n = std::sqrt(right.x * right.x + right.y * right.y + right.z * right.z); + if (n > 1e-9f) right = {right.x / n, right.y / n, right.z / n}; + t.heading = rotate(t.heading, right, angle); + orthonormalize(t.heading, t.up); + break; + } + case '^': { + Vec3 right{0.f, 0.f, 0.f}; + orthonormalize(t.heading, t.up); + right = {t.up.y * t.heading.z - t.up.z * t.heading.y, + t.up.z * t.heading.x - t.up.x * t.heading.z, + t.up.x * t.heading.y - t.up.y * t.heading.x}; + const float n = std::sqrt(right.x * right.x + right.y * right.y + right.z * right.z); + if (n > 1e-9f) right = {right.x / n, right.y / n, right.z / n}; + t.heading = rotate(t.heading, right, -angle); + orthonormalize(t.heading, t.up); + break; + } + case '\\': t.up = rotate(t.up, t.heading, angle); orthonormalize(t.heading, t.up); break; + case '/': t.up = rotate(t.up, t.heading, -angle); orthonormalize(t.heading, t.up); break; + case '[': + stack.push_back(t); + t.depth += 1; + break; + case ']': + if (!stack.empty()) { + t = stack.back(); + stack.pop_back(); + } + break; + default: + if (leafChars_[static_cast(ch)]) { + LSystemSegment seg; + seg.sx = t.pos.x; seg.sy = t.pos.y; seg.sz = t.pos.z; + seg.ex = t.pos.x; seg.ey = t.pos.y; seg.ez = t.pos.z; + seg.r0 = 0.f; seg.r1 = 0.f; seg.depth = t.depth; + seg.leaf = true; seg.leafSize = leafSize_; + seg.dx = t.heading.x; seg.dy = t.heading.y; seg.dz = t.heading.z; + out.segments.push_back(seg); + out.leafCount += 1; + } + break; + } + } +} + +void LSystem::generate(LSystemResult& out) const { + out.segments.clear(); + out.leafCount = 0; + out.derivation = derive(); + interpret(out.derivation, out); +} + +void LSystem::toPointSet(PointSet& out) const { + LSystemResult result; + generate(result); + for (const LSystemSegment& seg : result.segments) { + out.add(seg.sx, seg.sy, seg.sz); + out.add(seg.ex, seg.ey, seg.ez); + } +} + +} // namespace eve::procgen \ No newline at end of file diff --git a/src/modules/procgen/algorithms/LSystem.h b/src/modules/procgen/algorithms/LSystem.h new file mode 100644 index 00000000..06cfb534 --- /dev/null +++ b/src/modules/procgen/algorithms/LSystem.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include +#include + +namespace eve::procgen { + +class PointSet; + +/** + * @brief One drawn segment produced by an L-system turtle. + * + * Non-leaf segments are branch limbs (start -> end with end radii); leaf + * segments carry a foliage marker (position = end, direction = dx/dy/dz). + */ +struct LSystemSegment { + float sx = 0.f, sy = 0.f, sz = 0.f; // start + float ex = 0.f, ey = 0.f, ez = 0.f; // end + float r0 = 0.f, r1 = 0.f; // radius at start / end + int depth = 0; // bracket nesting at draw time + bool leaf = false; // foliage marker instead of a limb + float leafSize = 0.f; // foliage card size for leaf segments + float dx = 0.f, dy = 0.f, dz = 0.f; // turtle heading at a leaf marker +}; + +/** @brief Full result of expanding an L-system grammar and tracing the turtle. */ +struct LSystemResult { + std::vector segments; + std::string derivation; + int leafCount = 0; +}; + +/** + * @brief General stochastic bracketed L-system engine. + * + * A starting axiom is rewritten `iterations` times using production rules; a + * symbol may carry several weighted productions (chosen deterministically from + * the seed). The derived word is then interpreted by a 3D turtle: + * + * F draw forward one step f move forward, no draw + * + / - yaw (heading around up) & / ^ pitch (down / up) + * \ / / roll around the heading [ ] push / pop turtle state + * emit a leaf (see setLeafSymbols) + * + * Limb thickness falls off with bracket depth, so [ ] bracketing produces + * tapering branches. The engine is not tied to mesh output: call toPointSet() + * to recover segments as spline control points (roads, 2D layouts) or run the + * mesh.lsystem recipe for tree/plant meshes. A fixed seed reproduces the exact + * same result. + */ +class LSystem { +public: + LSystem(); + + /** @brief Set the starting string. @param axiom Axiom. */ + void setAxiom(const std::string& axiom); + /** @brief Add a deterministic production for one symbol. @param symbol Source symbol. @param production Replacement. */ + void addRule(char symbol, const std::string& production); + /** @brief Add weighted (stochastic) productions for one symbol. @param symbol Source symbol. @param productions Replacements. @param weights Selection weights. */ + void addRules(char symbol, const std::vector& productions, const std::vector& weights); + /** @brief Remove all production rules (the axiom and alphabet remain). */ + void clearRules(); + + /** @brief Set the turtle turn angle. @param degrees Angle in degrees. */ + void setAngle(float degrees); + /** @brief Set the forward distance per F. @param step Step length. */ + void setStep(float step); + /** @brief Set the grammar expansion count. @param iterations Rewrite count. */ + void setIterations(int iterations); + /** @brief Set the deterministic seed. @param seed Seed. */ + void setSeed(uint32_t seed); + /** @brief Set the initial heading (normalized). @param x X. @param y Y. @param z Z. */ + void setInitialHeading(float x, float y, float z); + /** @brief Set the trunk radius at depth 0. @param radius Radius. */ + void setBranchRadius(float radius); + /** @brief Set the per-depth radius multiplier. @param factor Factor in (0, 1]. */ + void setBranchRadiusFalloff(float factor); + /** @brief Set the foliage card size. @param size Size. */ + void setLeafSize(float size); + /** @brief Set the set of symbols that emit a leaf marker. @param symbols Character list, e.g. "L@". */ + void setLeafSymbols(const std::string& symbols); + /** @brief Bias growth toward a direction (phototropism). @param x X. @param y Y. @param z Z. */ + void setTropism(float x, float y, float z); + + /** @brief Current seed. @return Seed. */ + uint32_t getSeed() const; + /** @brief Current iteration count. @return Iterations. */ + int getIterations() const; + + /** @brief Expand the grammar `iterations` times. @return The derived word. */ + std::string derive() const; + /** @brief Expand then interpret with the turtle. @param out Result to fill. */ + void generate(LSystemResult& out) const; + /** @brief Generate and pack segment endpoints into a PointSet. @param out Set to fill. */ + void toPointSet(PointSet& out) const; + +private: + struct Vec3 { + float x = 0.f, y = 0.f, z = 0.f; + }; + struct TurtleState { + Vec3 pos, heading, up; + int depth = 0; + }; + using Rule = std::pair; // production, weight + + Vec3 rotate(const Vec3& v, const Vec3& axis, float angle) const; + void orthonormalize(Vec3& heading, Vec3& up) const; + void interpret(const std::string& word, LSystemResult& out) const; + + std::string axiom_; + std::vector rules_[256]; + float angleDeg_ = 25.f; + float step_ = 1.f; + int iterations_ = 5; + uint32_t seed_ = 1; + Vec3 heading0_ = {0.f, 1.f, 0.f}; + float branchRadius_ = 0.1f; + float radiusFalloff_ = 0.6f; + float leafSize_ = 0.4f; + bool leafChars_[256] = {}; + Vec3 tropism_ = {0.f, 0.f, 0.f}; +}; + +} // namespace eve::procgen \ No newline at end of file diff --git a/src/modules/procgen/algorithms/LSystemMesh.cpp b/src/modules/procgen/algorithms/LSystemMesh.cpp new file mode 100644 index 00000000..624fd758 --- /dev/null +++ b/src/modules/procgen/algorithms/LSystemMesh.cpp @@ -0,0 +1,202 @@ +#include "procgen/algorithms/LSystem.h" +#include "procgen/algorithms/LSystemMesh.h" +#include "procgen/algorithms/MarchingCubes.h" +#include "procgen/ParamSchema.h" + +#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; } +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); +} + +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)); + // Bark occupies the left side of the texture atlas; foliage the right. + 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 configurePreset(LSystem& ls, const std::string& style) { + ls.setLeafSymbols("L"); + if (style == "fern") { + ls.setAxiom("X"); + ls.clearRules(); + ls.addRule('X', "F-[[X]+X]+F[+FX]-X"); + ls.addRule('F', "FF"); + ls.setAngle(25.f); + ls.setInitialHeading(0.f, 1.f, 0.f); + ls.setBranchRadius(0.05f); + ls.setBranchRadiusFalloff(0.8f); + } else if (style == "plant") { + ls.setAxiom("F"); + ls.clearRules(); + ls.addRule('F', "F[+F]F[-F]F"); + ls.setAngle(26.f); + ls.setInitialHeading(0.f, 1.f, 0.f); + ls.setBranchRadius(0.09f); + ls.setBranchRadiusFalloff(0.72f); + } else if (style == "weed") { + ls.setAxiom("X"); + ls.clearRules(); + ls.addRule('X', "F[-X][+X]FX"); + ls.addRule('X', "F[-X]FX"); + ls.addRule('X', "F[&X]F^X"); + ls.setAngle(28.f); + ls.setInitialHeading(0.f, 1.f, 0.f); + ls.setBranchRadius(0.06f); + ls.setBranchRadiusFalloff(0.7f); + } else { // tree (default) + ls.setAxiom("A"); + ls.clearRules(); + ls.addRules('A', {"F[&L A]F[&L A]^F[&L A]F L A", "F[&L A]F L A", "F L A"}, + {2.f, 1.f, 1.f}); + ls.setAngle(22.f); + ls.setInitialHeading(0.f, 1.f, 0.f); + ls.setBranchRadius(0.16f); + ls.setBranchRadiusFalloff(0.62f); + } +} + +} // namespace + +bool generateLSystemMesh(const Params& params, MeshBuild& out, std::string& error) { + const std::string style = params.getString("style", "tree"); + if (style != "tree" && style != "fern" && style != "plant" && style != "weed") { + error = "mesh.lsystem: unknown style '" + style + "'"; + return false; + } + const std::string leafMode = params.getString("leafMode", "cards"); + if (leafMode != "cards" && leafMode != "none") { + error = "mesh.lsystem: unknown leafMode '" + leafMode + "'"; + return false; + } + + LSystem ls; + configurePreset(ls, style); + ls.setSeed(params.getSeed()); + ls.setIterations(params.getInt("iterations", 5)); + ls.setAngle(params.getFloat("angle", 22.f)); + ls.setBranchRadius(params.getFloat("branchRadius", 0.16f)); + ls.setBranchRadiusFalloff(params.getFloat("radiusFalloff", 0.62f)); + ls.setLeafSize(params.getFloat("leafSize", 0.4f)); + ls.setTropism(0.f, params.getFloat("tropism", 0.f), 0.f); + const int branchSegments = std::clamp(params.getInt("branchSegments", 6), 3, 24); + const int leavesPerMarker = std::max(1, params.getInt("leavesPerMarker", 1)); + + LSystemResult result; + ls.generate(result); + if (result.segments.empty()) { + error = "mesh.lsystem: generated no geometry"; + return false; + } + + out.reserve(int(result.segments.size() * 12), int(result.segments.size() * 24)); + for (const LSystemSegment& seg : result.segments) { + if (seg.leaf) { + if (leafMode == "none") continue; + const V3 c = {seg.ex, seg.ey, seg.ez}; + const V3 dir = {seg.dx, seg.dy, seg.dz}; + for (int i = 0; i < leavesPerMarker; ++i) { + const float twist = float(i) * 2.39996323f; // golden angle + addLeafCard(out, c, dir, seg.leafSize, twist); + } + continue; + } + const V3 a = {seg.sx, seg.sy, seg.sz}; + const V3 b = {seg.ex, seg.ey, seg.ez}; + addTaperedCylinder(out, a, b, std::max(1e-4f, seg.r0), std::max(1e-4f, seg.r1), branchSegments); + } + + out.setMeta("recipe", "mesh.lsystem"); + out.setMeta("style", style); + out.setMeta("leafMode", leafMode); + out.setMeta("seed", std::to_string(params.getSeed())); + if (out.empty()) { + error = "mesh.lsystem: generated an empty mesh"; + return false; + } + return true; +} + +void registerLSystemRecipes(MeshRecipeRegistry& registry) { + RecipeDescriptor schema{std::string("mesh.lsystem"), "L-System Plant", "Mesh", {}}; + schema.params.push_back(ParamDescriptor::integer("seed", "Seed", 1, 0, 2147483647)); + schema.params.push_back( + ParamDescriptor::choice("style", "Style", "tree", {"tree", "fern", "plant", "weed"})); + schema.params.push_back(ParamDescriptor::choice("leafMode", "Leaf Mode", "cards", {"cards", "none"})); + schema.params.push_back(ParamDescriptor::integer("iterations", "Iterations", 5, 1, 8)); + schema.params.push_back(ParamDescriptor::floating("angle", "Turn Angle", 22.f, 5.f, 90.f, 1.f)); + schema.params.push_back(ParamDescriptor::floating("branchRadius", "Branch Radius", 0.16f, 0.005f, 5.f, 0.005f)); + schema.params.push_back(ParamDescriptor::floating("radiusFalloff", "Radius Falloff", 0.62f, 0.05f, 1.f, 0.01f)); + schema.params.push_back(ParamDescriptor::floating("leafSize", "Leaf Size", 0.4f, 0.02f, 10.f, 0.01f)); + schema.params.push_back(ParamDescriptor::floating("tropism", "Tropism", 0.f, 0.f, 1.f, 0.01f)); + schema.params.push_back(ParamDescriptor::integer("branchSegments", "Branch Segments", 6, 3, 24)); + schema.params.push_back(ParamDescriptor::integer("leavesPerMarker", "Leaves Per Marker", 1, 1, 8)); + registry.registerRecipe(std::move(schema), generateLSystemMesh); +} + +} // namespace eve::procgen \ No newline at end of file diff --git a/src/modules/procgen/algorithms/LSystemMesh.h b/src/modules/procgen/algorithms/LSystemMesh.h new file mode 100644 index 00000000..b8da5d63 --- /dev/null +++ b/src/modules/procgen/algorithms/LSystemMesh.h @@ -0,0 +1,18 @@ +#pragma once + +#include "procgen/MeshBuild.h" +#include "procgen/Params.h" + +#include + +namespace eve::procgen { + +class MeshRecipeRegistry; + +/** @brief Register the `mesh.lsystem` grammar-based plant recipe. @param registry Registry to populate. */ +void registerLSystemRecipes(MeshRecipeRegistry& registry); + +/** @brief Build a grammar-based tree/plant mesh from Params. */ +bool generateLSystemMesh(const Params& params, MeshBuild& out, std::string& error); + +} // namespace eve::procgen \ No newline at end of file diff --git a/src/modules/procgen/algorithms/MarchingCubes.cpp b/src/modules/procgen/algorithms/MarchingCubes.cpp index 4b8f7bff..f5af759d 100644 --- a/src/modules/procgen/algorithms/MarchingCubes.cpp +++ b/src/modules/procgen/algorithms/MarchingCubes.cpp @@ -5,6 +5,7 @@ #include "procgen/algorithms/TreeMesh.h" #include "procgen/algorithms/BushMesh.h" #include "procgen/algorithms/LinearStructure.h" +#include "procgen/algorithms/LSystemMesh.h" #include "procgen/urban/UrbanOutput.h" #include "procgen/algorithms/CastleMesh.h" @@ -651,6 +652,7 @@ void MeshRecipeRegistry::registerBuiltins() { 1000.f, 0.1f)); registerRecipe(std::move(tower), generateSkyscraperMesh); registerLinearStructureRecipes(*this); + registerLSystemRecipes(*this); urban::registerUrbanMeshRecipes(*this); registerCastleMeshRecipe(*this); builtinsRegistered_ = true; diff --git a/src/modules/profiler/Profiler.cpp b/src/modules/profiler/Profiler.cpp new file mode 100644 index 00000000..239a5a56 --- /dev/null +++ b/src/modules/profiler/Profiler.cpp @@ -0,0 +1,132 @@ +#include "profiler/Profiler.h" + +#include "common/GpuTimer.h" +#include "common/Capability.h" + +#include + +#include +#include + +namespace eve::profiler { + +Module_IMPL(Profiler, new Profiler()); + +namespace { +int64_t nowNs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} +} // namespace + +Profiler::Profiler() { eve::debug::setRenderTracer(this); } + +Profiler::~Profiler() { + if (eve::debug::renderTracer() == this) eve::debug::setRenderTracer(nullptr); +} + +void Profiler::setEnabled(bool on) { + eve::prof::Profiler::setEnabled(on); + if (!on) { + frameMs_ = 0.f; + } +} + +bool Profiler::enabled() const { return eve::prof::Profiler::enabled(); } + +void Profiler::reset() { + eve::prof::Profiler::reset(); + frameMs_ = 0.f; +} + +void Profiler::beginFrame() { frameBeginNs_ = nowNs(); } + +void Profiler::endFrame() { + if (!enabled()) return; + const double totalMs = static_cast(nowNs() - frameBeginNs_) / 1'000'000.0; + frameMs_ = static_cast(totalMs); + eve::prof::Profiler::frameMark(); +} + +void Profiler::begin(const char* name) { + if (!enabled()) return; + eve::prof::Profiler::zoneBegin(name ? name : "scope", nullptr); +} + +void Profiler::end() { + if (!enabled()) return; + eve::prof::Profiler::zoneEnd(); +} + +void Profiler::frameBegin() { beginFrame(); } + +void Profiler::frameEnd() { endFrame(); } + +void Profiler::passBegin(const char* name) { + if (!enabled()) return; + eve::prof::Profiler::zoneBegin(name ? name : "pass", "graphics"); +} + +void Profiler::passEnd(const char* name) { + if (!enabled()) return; + eve::prof::Profiler::zoneEnd(); +} + +bool Profiler::hasFrame() const { return eve::prof::Profiler::hasFrame(); } + +float Profiler::frameMs() const { return frameMs_; } + +float Profiler::gpuFrameMs() const { + auto* t = eve::cap::query(); + return t ? t->gpuFrameMs() : 0.f; +} + +std::string Profiler::textReport() const { + std::string report = eve::prof::Profiler::textReport(); + auto* t = eve::cap::query(); + if (t && t->gpuTimingAvailable()) { + char buf[64]; + std::snprintf(buf, sizeof(buf), " GPU frame: %.2f ms\n", t->gpuFrameMs()); + report += buf; + } + return report; +} + +ssq::Object Profiler::capture() { + ssq::Array out(vm_); + for (const auto& s : eve::prof::Profiler::lastFrame()) { + ssq::Table t(vm_); + t.set("module", s.module); + t.set("name", s.name); + t.set("thread", s.thread); + t.set("selfMs", static_cast(s.selfMs)); + t.set("totalMs", static_cast(s.totalMs)); + t.set("count", s.count); + out.push(t); + } + return out; +} + +void Profiler::expose(ssq::Table& table) { + if (Profiler* self = Profiler::create()) self->vm_ = table.getHandle(); + auto cls = table.addClass(name, Profiler::create, false); + expose(cls); +} + +void Profiler::expose(ssq::Class& cls) { + cls.addFunc("setEnabled", &Profiler::setEnabled); + cls.addFunc("enabled", &Profiler::enabled); + cls.addFunc("reset", &Profiler::reset); + cls.addFunc("beginFrame", &Profiler::beginFrame); + cls.addFunc("endFrame", &Profiler::endFrame); + cls.addFunc("begin", &Profiler::begin); + cls.addFunc("end", &Profiler::end); + cls.addFunc("hasFrame", &Profiler::hasFrame); + cls.addFunc("frameMs", &Profiler::frameMs); + cls.addFunc("gpuFrameMs", &Profiler::gpuFrameMs); + cls.addFunc("textReport", &Profiler::textReport); + cls.addFunc("capture", &Profiler::capture); +} + +} // namespace eve::profiler diff --git a/src/modules/profiler/Profiler.h b/src/modules/profiler/Profiler.h new file mode 100644 index 00000000..72aaadf0 --- /dev/null +++ b/src/modules/profiler/Profiler.h @@ -0,0 +1,96 @@ +#pragma once + +#include "common/Module.h" +#include "common/Profile.h" +#include "common/RenderTrace.h" + +#include + +#include +#include + +namespace ssq { +class Object; +} + +namespace eve::profiler { + +/** + * @brief Script-facing wrapper around the engine-wide profiler core + * (eve::prof, see common/Profile.h). + * + * The core collects scoped zones from every engine system (physics, animation, + * particles, audio, ui, scene, graphics passes, ...) into a per-frame call tree + * with self/total time per module and per zone. This module exposes that data + * to Squirrel and adds GPU frame timing (via eve::service::IGpuTimer). + * + * Script usage: + * @code + * profiler.setEnabled(true); // start collecting engine-wide zones + * // ... run a few frames ... + * print(profiler.textReport()); // per-module/per-zone hotspot report + * local rows = profiler.capture(); // array of {module,name,selfMs,totalMs,count,thread} + * profiler.setEnabled(false); + * @endcode + * + * The module installs itself as the IRenderTracer, so each render pass becomes a + * "graphics" zone and the render frame boundary drives automatic per-frame + * aggregation (no game script changes required). beginFrame()/endFrame() are also + * exposed for manual frame delimitation around arbitrary code. + */ +class Profiler : public Module, public eve::debug::IRenderTracer { +public: + Module_REG(Profiler); + Profiler(); + ~Profiler() override; + + /** @brief Enables/disables engine-wide collection. Disabled = cheap no-op. */ + void setEnabled(bool on); + /** @brief True when collection is active. */ + bool enabled() const; + /** @brief Clears all collected data. */ + void reset(); + + /** @brief Marks the start of a frame for total-frame timing. */ + void beginFrame(); + /** @brief Finalizes the current frame and aggregates the collected zones. */ + void endFrame(); + + /** + * @brief Opens a user timing scope under the current frame. + * @param name Scope name (must be non-null). + */ + void begin(const char* name); + /** @brief Closes the innermost open user scope. */ + void end(); + + /** @brief True once at least one frame has been aggregated. */ + bool hasFrame() const; + /** @brief Total CPU frame time of the last frame, in milliseconds. */ + float frameMs() const; + /** @brief GPU execution time of the last presented frame (0 when unavailable). */ + float gpuFrameMs() const; + /** @brief Human-readable hotspot report (per module / per zone). */ + std::string textReport() const; + /** + * @brief Structured report of the last frame. + * @return Array of tables {module, name, thread, selfMs, totalMs, count}, + * sorted by self time descending. + */ + ssq::Object capture(); + + // IRenderTracer: route render passes into the core as "graphics" zones and + // drive automatic per-frame aggregation from the render frame boundary. + void frameBegin() override; + void frameEnd() override; + void passBegin(const char* name) override; + void passEnd(const char* name) override; + void draw(const char* api, const char* detail) override {} + +private: + int64_t frameBeginNs_ = 0; + float frameMs_ = 0.f; + HSQUIRRELVM vm_ = nullptr; +}; + +} // namespace eve::profiler diff --git a/src/modules/scene/SceneCapabilities.cpp b/src/modules/scene/SceneCapabilities.cpp index f990f17d..db406c6c 100644 --- a/src/modules/scene/SceneCapabilities.cpp +++ b/src/modules/scene/SceneCapabilities.cpp @@ -1,4 +1,5 @@ #include "common/Capability.h" +#include "common/ProcgenSceneSink.h" #include "common/SceneQuery.h" #include "scene/Scene.h" #include "scene/SceneHost.h" @@ -7,6 +8,8 @@ #include #include #include +#include +#include #include namespace eve::scene { @@ -149,11 +152,116 @@ public: void syncTransforms() override { TransformSystem::updateAll(); } }; +class ProcgenSceneSinkImpl final : public eve::IProcgenSceneSink { +public: + bool applyBatch(const std::string& batchId, + const std::vector& instances) override { + if (batchId.empty()) return false; + SceneHost* host = hostByName(hostName(batchId)); + if (!host) host = borrowSceneResult(SceneHost::createHost(hostName(batchId))); + if (!host) return false; + + NodeDesc root; + root.id = "pcg-root"; + root.key = "pcg-root"; + root.name = batchId; + root.tags = {"pcg", "pcg.batch"}; + root.children.reserve(instances.size()); + for (const auto& instance : instances) { + NodeDesc child; + child.id = instance.id; + child.key = instance.id; + child.name = instance.asset; + child.x = instance.x; + child.y = instance.y; + child.z = instance.z; + child.yaw = instance.yaw; + child.sx = instance.scaleX; + child.sy = instance.scaleY; + child.sz = instance.scaleZ; + child.tags = {"pcg", "pcg.instance"}; + if (!instance.asset.empty()) child.tags.push_back("pcg.asset:" + instance.asset); + root.children.push_back(std::move(child)); + } + std::unordered_set nextIds; + nextIds.reserve(instances.size()); + for (const auto& instance : instances) nextIds.insert(instance.id); + const auto previous = ids_.find(batchId); + const auto& previousIds = previous == ids_.end() ? emptyIds_ : previous->second; + Stats stats; + for (const auto& id : nextIds) { + if (previousIds.find(id) == previousIds.end()) ++stats.created; + else ++stats.reused; + } + for (const auto& id : previousIds) { + if (nextIds.find(id) == nextIds.end()) ++stats.removed; + } + + host->setVisible(true); + host->setTreeReconcile(std::move(root)); + TransformSystem::updateHost(host); + counts_[batchId] = int(instances.size()); + ids_[batchId] = std::move(nextIds); + stats_[batchId] = stats; + return true; + } + + bool removeBatch(const std::string& batchId) override { + SceneHost* host = hostByName(hostName(batchId)); + if (!host) return false; + NodeDesc root; + root.id = "pcg-root"; + root.key = "pcg-root"; + root.name = batchId; + root.tags = {"pcg", "pcg.batch"}; + host->setTree(std::move(root)); + host->setVisible(false); + Stats stats; + const auto ids = ids_.find(batchId); + if (ids != ids_.end()) stats.removed = int(ids->second.size()); + stats_[batchId] = stats; + counts_.erase(batchId); + ids_.erase(batchId); + return true; + } + + int instanceCount(const std::string& batchId) const override { + const auto found = counts_.find(batchId); + return found == counts_.end() ? 0 : found->second; + } + int lastCreatedCount(const std::string& batchId) const override { + const auto found = stats_.find(batchId); + return found == stats_.end() ? 0 : found->second.created; + } + int lastReusedCount(const std::string& batchId) const override { + const auto found = stats_.find(batchId); + return found == stats_.end() ? 0 : found->second.reused; + } + int lastRemovedCount(const std::string& batchId) const override { + const auto found = stats_.find(batchId); + return found == stats_.end() ? 0 : found->second.removed; + } + +private: + struct Stats { + int created = 0; + int reused = 0; + int removed = 0; + }; + static std::string hostName(const std::string& batchId) { return "__pcg/" + batchId; } + const std::unordered_set emptyIds_; + std::unordered_map counts_; + std::unordered_map> ids_; + std::unordered_map stats_; +}; + } // namespace void registerSceneCapabilities() { static SceneQueryImpl impl; + static ProcgenSceneSinkImpl procgenSink; eve::cap::provide(&impl); + eve::cap::provide(&procgenSink); } } // namespace eve::scene diff --git a/src/modules/scene/TransformSystem.cpp b/src/modules/scene/TransformSystem.cpp index 69cdb6d4..b3dd4ec6 100644 --- a/src/modules/scene/TransformSystem.cpp +++ b/src/modules/scene/TransformSystem.cpp @@ -1,5 +1,6 @@ #include "scene/TransformSystem.h" +#include "common/Profile.h" #include "scene/SceneHost.h" #include "scene/SceneLink.h" @@ -161,6 +162,7 @@ void TransformSystem::updateHost(SceneHost *host) { } void TransformSystem::updateAll() { + EV_PROFILE_MODULE("scene", "TransformSystem::updateAll"); if (ecs::current()->getManager() == nullptr) return; auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { diff --git a/src/modules/schema/Schema.cpp b/src/modules/schema/Schema.cpp index e9968735..32706bd1 100644 --- a/src/modules/schema/Schema.cpp +++ b/src/modules/schema/Schema.cpp @@ -6,6 +6,11 @@ #include +#include +#include +#include +#include + namespace eve::schema { Module_IMPL(Schema, new Schema()); @@ -22,6 +27,266 @@ eve::Result Schema::registerJson(const std::string& json) { return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } +namespace { + +/** @brief The schema type implied by a Squirrel slot type. */ +ValueType typeFromSquirrel(ssq::Type type) { + switch (type) { + case ssq::Type::INTEGER: return ValueType::Integer; + case ssq::Type::FLOAT: return ValueType::Number; + case ssq::Type::BOOL: return ValueType::Boolean; + case ssq::Type::STRING: return ValueType::String; + case ssq::Type::ARRAY: return ValueType::Array; + case ssq::Type::TABLE: return ValueType::Object; + default: return ValueType::Any; + } +} + +/** @brief Renders a Squirrel scalar at `index` as JSON text; empty otherwise. */ +std::string scalarToJson(HSQUIRRELVM vm, SQInteger index) { + switch (sq_gettype(vm, index)) { + 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))) { + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%g", static_cast(value)); + return buffer; + } + break; + } + case OT_BOOL: { + SQBool value = SQFalse; + if (SQ_SUCCEEDED(sq_getbool(vm, index, &value))) return value ? "true" : "false"; + break; + } + case OT_STRING: { + const SQChar* value = nullptr; + if (SQ_SUCCEEDED(sq_getstring(vm, index, &value)) && value) { + std::string out = "\""; + for (const SQChar* p = value; *p; ++p) { + if (*p == '"' || *p == '\\') out += '\\'; + out += *p; + } + out += '"'; + return out; + } + break; + } + default: break; + } + return {}; +} + +bool getAttrString(HSQUIRRELVM vm, SQInteger tableIdx, const char* key, std::string& out) { + const SQInteger top = sq_gettop(vm); + sq_push(vm, tableIdx); + sq_pushstring(vm, key, static_cast(std::strlen(key))); + bool found = false; + if (SQ_SUCCEEDED(sq_get(vm, -2)) && sq_gettype(vm, -1) == OT_STRING) { + const SQChar* value = nullptr; + if (SQ_SUCCEEDED(sq_getstring(vm, -1, &value)) && value) { + out = value; + found = true; + } + } + sq_settop(vm, top); + return found; +} + +bool getAttrBool(HSQUIRRELVM vm, SQInteger tableIdx, const char* key, bool& out) { + const SQInteger top = sq_gettop(vm); + sq_push(vm, tableIdx); + sq_pushstring(vm, key, static_cast(std::strlen(key))); + bool found = false; + if (SQ_SUCCEEDED(sq_get(vm, -2)) && sq_gettype(vm, -1) == OT_BOOL) { + SQBool value = SQFalse; + if (SQ_SUCCEEDED(sq_getbool(vm, -1, &value))) { + out = value != SQFalse; + found = true; + } + } + sq_settop(vm, top); + return found; +} + +bool getAttrInt(HSQUIRRELVM vm, SQInteger tableIdx, const char* key, int& out) { + const SQInteger top = sq_gettop(vm); + sq_push(vm, tableIdx); + sq_pushstring(vm, key, static_cast(std::strlen(key))); + bool found = false; + if (SQ_SUCCEEDED(sq_get(vm, -2)) && sq_gettype(vm, -1) == OT_INTEGER) { + SQInteger value = 0; + if (SQ_SUCCEEDED(sq_getinteger(vm, -1, &value))) { + out = static_cast(value); + found = true; + } + } + sq_settop(vm, top); + return found; +} + +bool getAttrNumber(HSQUIRRELVM vm, SQInteger tableIdx, const char* key, double& out) { + const SQInteger top = sq_gettop(vm); + sq_push(vm, tableIdx); + sq_pushstring(vm, key, static_cast(std::strlen(key))); + bool found = false; + if (SQ_SUCCEEDED(sq_get(vm, -2)) && sq_gettype(vm, -1) == OT_INTEGER) { + SQInteger value = 0; + if (SQ_SUCCEEDED(sq_getinteger(vm, -1, &value))) { + out = static_cast(value); + found = true; + } + } else if (SQ_SUCCEEDED(sq_get(vm, -2)) && sq_gettype(vm, -1) == OT_FLOAT) { + SQFloat value = 0; + if (SQ_SUCCEEDED(sq_getfloat(vm, -1, &value))) { + out = static_cast(value); + found = true; + } + } + sq_settop(vm, top); + return found; +} + +void getAttrStringArray(HSQUIRRELVM vm, SQInteger tableIdx, const char* key, std::vector& out) { + const SQInteger top = sq_gettop(vm); + sq_push(vm, tableIdx); + sq_pushstring(vm, key, static_cast(std::strlen(key))); + if (SQ_SUCCEEDED(sq_get(vm, -2)) && sq_gettype(vm, -1) == OT_ARRAY) { + const SQInteger arrayIdx = sq_gettop(vm); + const SQInteger length = sq_getsize(vm, arrayIdx); + for (SQInteger i = 0; i < length; ++i) { + sq_pushinteger(vm, i); + if (SQ_SUCCEEDED(sq_get(vm, arrayIdx)) && sq_gettype(vm, -1) == OT_STRING) { + const SQChar* value = nullptr; + if (SQ_SUCCEEDED(sq_getstring(vm, -1, &value)) && value) out.push_back(value); + } + sq_pop(vm, 1); + } + } + sq_settop(vm, top); +} + +void applyFieldAttributes(HSQUIRRELVM vm, SQInteger attrsIdx, FieldDefinition& field) { + std::string text; + if (getAttrString(vm, attrsIdx, "type", text)) + if (auto type = valueTypeFromString(text)) field.type = *type; + if (getAttrString(vm, attrsIdx, "elementType", text)) + if (auto type = valueTypeFromString(text)) field.elementType = *type; + if (getAttrString(vm, attrsIdx, "title", text)) field.title = text; + if (getAttrString(vm, attrsIdx, "description", text)) field.description = text; + if (getAttrString(vm, attrsIdx, "reference", text)) field.reference = text; + if (getAttrString(vm, attrsIdx, "defaultJson", text)) field.defaultJson = text; + double number = 0.0; + if (getAttrNumber(vm, attrsIdx, "minimum", number) || getAttrNumber(vm, attrsIdx, "min", number)) + field.minimum = number; + if (getAttrNumber(vm, attrsIdx, "maximum", number) || getAttrNumber(vm, attrsIdx, "max", number)) + field.maximum = number; + int integer = 0; + if (getAttrInt(vm, attrsIdx, "minLength", integer)) field.minLength = integer; + if (getAttrInt(vm, attrsIdx, "maxLength", integer)) field.maxLength = integer; + bool boolean = false; + if (getAttrBool(vm, attrsIdx, "required", boolean)) field.required = boolean; + getAttrStringArray(vm, attrsIdx, "values", field.enumValues); + if (field.enumValues.empty()) getAttrStringArray(vm, attrsIdx, "enum", field.enumValues); +} + +} // namespace + +eve::Result Schema::registerFromClass(const ssq::Object& classOrInstance) { + const ssq::Type type = classOrInstance.getType(); + if (type != ssq::Type::CLASS && type != ssq::Type::INSTANCE) { + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, + "registerFromClass expects a class or an instance of a class", "class")); + } + HSQUIRRELVM vm = classOrInstance.getHandle(); + const SQInteger top = sq_gettop(vm); + + sq_pushobject(vm, classOrInstance.getRaw()); + if (type == ssq::Type::INSTANCE && SQ_FAILED(sq_getclass(vm, -1))) { + sq_settop(vm, top); + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "could not resolve the instance's class", "class")); + } + const SQInteger clsIdx = sq_gettop(vm); + + SchemaDefinition definition; + const SQInteger metaTop = sq_gettop(vm); + sq_push(vm, clsIdx); + sq_pushnull(vm); // null key selects class-level attributes + if (SQ_SUCCEEDED(sq_getattributes(vm, -2)) && sq_gettype(vm, -1) == OT_TABLE) { + const SQInteger attrsIdx = sq_gettop(vm); + std::string meta; + if (getAttrString(vm, attrsIdx, "id", meta)) definition.id = meta; + if (getAttrString(vm, attrsIdx, "title", meta)) definition.title = meta; + if (getAttrString(vm, attrsIdx, "description", meta)) definition.description = meta; + int version = 1; + if (getAttrInt(vm, attrsIdx, "version", version)) definition.version = version; + bool strict = false; + if (getAttrBool(vm, attrsIdx, "additionalProperties", strict)) definition.additionalProperties = strict; + } + sq_settop(vm, metaTop); + + if (definition.id.empty()) { + sq_settop(vm, top); + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, + "schema id is required (set the class-level attribute 'id')", "schema.id")); + } + + std::unordered_set names; + sq_push(vm, clsIdx); + sq_pushnull(vm); + while (SQ_SUCCEEDED(sq_next(vm, -2))) { + if (sq_gettype(vm, -2) != OT_STRING) { + sq_pop(vm, 2); + continue; + } + const SQChar* memberName = nullptr; + if (!SQ_SUCCEEDED(sq_getstring(vm, -2, &memberName)) || !memberName) { + sq_pop(vm, 2); + continue; + } + const SQObjectType memberType = sq_gettype(vm, -1); + if (memberType == OT_CLOSURE || memberType == OT_NATIVECLOSURE) { + sq_pop(vm, 2); + continue; + } + const std::string name(memberName); + if (!names.insert(name).second) { + sq_pop(vm, 2); + continue; + } + + FieldDefinition field; + field.name = name; + field.type = typeFromSquirrel(static_cast(memberType)); + field.defaultJson = scalarToJson(vm, -1); + + const SQInteger attrsTop = sq_gettop(vm); + sq_push(vm, clsIdx); + sq_pushstring(vm, field.name.c_str(), static_cast(field.name.size())); + if (SQ_SUCCEEDED(sq_getattributes(vm, -2)) && sq_gettype(vm, -1) == OT_TABLE) { + applyFieldAttributes(vm, sq_gettop(vm), field); + } + sq_settop(vm, attrsTop); + + definition.fields.push_back(std::move(field)); + sq_pop(vm, 2); + } + sq_settop(vm, top); + + auto result = SchemaRegistry::registerSchema(definition); + if (!result.ok()) return eve::Result::failure(result.status()); + validationErrors_.clear(); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); +} + void Schema::clear() { SchemaRegistry::clear(); validationErrors_.clear(); @@ -184,6 +449,14 @@ void Schema::expose(ssq::Class& cls) { } return eve::script::projectResult(vm, value->registerJson(json)); }); + cls.addFunc("registerFromClass", [vm](Schema* value, const ssq::Object& classOrInstance) { + if (!value) { + return eve::script::projectResult( + vm, eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "schema facade must not be null", "schema"))); + } + return eve::script::projectResult(vm, value->registerFromClass(classOrInstance)); + }); cls.addFunc("clear", &Schema::clear); cls.addFunc("has", &Schema::has); cls.addFunc("hasVersion", &Schema::hasVersion); diff --git a/src/modules/schema/Schema.h b/src/modules/schema/Schema.h index 1ef886f1..c445156c 100644 --- a/src/modules/schema/Schema.h +++ b/src/modules/schema/Schema.h @@ -4,6 +4,8 @@ #include "common/Result.h" #include "schema/SchemaTypes.h" +#include + #include #include @@ -21,6 +23,17 @@ public: * @return Success, or a structured parse/validation/conflict failure. */ [[nodiscard]] eve::Result registerJson(const std::string& json); + /** @brief Registers a schema reflected from a script class or instance. + * + * Class-level `` attributes supply `id`, `version`, `title`, + * `description` and `additionalProperties`. Each non-method member becomes + * a field: its default value infers the type and the JSON default, and + * member-level `` attributes carry `required`, `type`, + * `elementType`, `title`, `description`, `reference`, `defaultJson`, + * `values`, `minimum`/`min`, `maximum`/`max`, `minLength`, `maxLength`. + * @return Success, or a structured reflection/registration failure. + */ + [[nodiscard]] eve::Result registerFromClass(const ssq::Object& classOrInstance); /** @brief Removes all registered schemas and cached validation errors. */ void clear(); /** @brief Returns whether a stable schema id is registered. */ diff --git a/src/modules/ui/UISystem.cpp b/src/modules/ui/UISystem.cpp index 7dad53a3..c193b06b 100644 --- a/src/modules/ui/UISystem.cpp +++ b/src/modules/ui/UISystem.cpp @@ -6,6 +6,7 @@ #include "ui/UIBackend.h" #include "common/Module.h" +#include "common/Profile.h" #include "graphics/Graphics.h" #include @@ -1220,6 +1221,7 @@ UIHostHandle UISystem::findHostByOwner(uint32_t ownerId) { } void UISystem::render() { + EV_PROFILE_MODULE("ui", "UISystem::render"); if (ecs::current()->getManager() == nullptr) return; applyThemeToImGui(globalTheme()); diff --git a/src/scripts/load.nut b/src/scripts/load.nut index 8ecc0971..2f667ec8 100644 --- a/src/scripts/load.nut +++ b/src/scripts/load.nut @@ -409,7 +409,14 @@ function soft_reload_scripts() { // eve_asset_reload hook. Shared by local watch and remote sync. function handle_change(p) { if (path_endswith(p, ".nut")) { - track_script(p); + local isModule = false; + try { + if ("reloadScriptModule" in eve) isModule = eve.reloadScriptModule(p); + } catch (e) { + report_reload_failure("module hot-reload failed: " + p + ": " + e); + return; + } + if (!isModule) track_script(p); soft_reload_scripts(); return; } @@ -434,7 +441,7 @@ function handle_change(p) { function poll_hot_reload() { if (!config.hotReload) return; if (!has_module("fs")) return; - local needScripts = false; + local scripts = []; local assets = []; while (true) { local kind = fs.pollWatch(); @@ -451,14 +458,24 @@ function poll_hot_reload() { } } if (path_endswith(p, ".nut")) { - track_script(p); - needScripts = true; + scripts.append(p); } else { assets.append(p); } } - if (needScripts) + if (scripts.len() > 0) { + foreach (p in scripts) { + local isModule = false; + try { + if ("reloadScriptModule" in eve) isModule = eve.reloadScriptModule(p); + } catch (e) { + report_reload_failure("module hot-reload failed: " + p + ": " + e); + return; + } + if (!isModule) track_script(p); + } soft_reload_scripts(); + } foreach (p in assets) { handle_change(p); } diff --git a/src/scripts/scene_director.nut b/src/scripts/scene_director.nut index b17d2b8f..7d2293f4 100644 --- a/src/scripts/scene_director.nut +++ b/src/scripts/scene_director.nut @@ -20,7 +20,7 @@ // A host game can also load it at boot (see examples/ai-stage): // compilestring(eve.sceneDirectorScript)(); -if (!("scene_director" in getroottable())) scene_director <- null; +persist scene_director = null // ---- internal helpers (roottable scope; reused across reinstalls) ---- diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0d96ec05..7f57d5a6 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -33,6 +33,8 @@ file(GLOB all_test_cpp decal_script.cpp crowd.cpp crowd_script.cpp + profiler.cpp + profiler_script.cpp crashlog.cpp resource.cpp resource_account.cpp @@ -161,6 +163,15 @@ file(GLOB all_test_cpp procgen_core_boundary.cpp procgen_schema_script.cpp procgen_pointset.cpp + procgen_lsystem.cpp + procgen_poisson.cpp + procgen_spatial_data.cpp + procgen_runtime_generation.cpp + procgen_scene_sink.cpp + procgen_point_graph.cpp + procgen_point_graph_stress.cpp + procgen_biome.cpp + procgen_shape_grammar.cpp urban_generator.cpp housegen.cpp housegen_render.cpp @@ -190,6 +201,7 @@ file(GLOB all_test_cpp shader.cpp hair.cpp grass.cpp + hd2d.cpp spritestack.cpp daynight.cpp weather.cpp @@ -247,6 +259,7 @@ file(GLOB all_test_cpp transaction_participant.cpp rx.cpp runtime.cpp + language_index.cpp script_compiler.cpp script_config.cpp script_module.cpp @@ -264,6 +277,7 @@ file(GLOB all_test_cpp editor_document_asset_v2.cpp editor_extension_v2.cpp editor_graph_material_v2.cpp + editor_pcg_graph.cpp editor_persistence_v2.cpp editor_property_v2.cpp editor_runtime_manifest_v2.cpp @@ -356,6 +370,7 @@ file(GLOB all_test_scripts timer.nut decal.nut crowd.nut + profiler.nut ) add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/nut_scripts.cpp COMMAND ${CMAKE_COMMAND} -DOUTPUT_DIR="${CMAKE_CURRENT_BINARY_DIR}" -P "${CMAKE_SOURCE_DIR}/cmake/integrate.cmake" diff --git a/test/cmdline.cpp b/test/cmdline.cpp index 71a1a96f..63072c0e 100644 --- a/test/cmdline.cpp +++ b/test/cmdline.cpp @@ -83,6 +83,11 @@ void writeFile(const std::filesystem::path& p, const std::string& content) { ofs << content; } +std::string readTextFile(const std::filesystem::path& p) { + std::ifstream in(p, std::ios::binary); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; +} + // Reads the ZIP central directory (EOCD -> central entries) and returns the // entry names in archive order. Returns {} when the file is not a valid ZIP. std::vector zipEntryNames(const std::filesystem::path& p) { @@ -336,6 +341,12 @@ TEST_CASE("cmdline.createScaffoldsGame") { } REQUIRE(std::filesystem::is_regular_file(dir / "mygame" / "config.nut")); REQUIRE(std::filesystem::is_regular_file(dir / "mygame" / "main.nut")); + const std::string config = readTextFile(dir / "mygame" / "config.nut"); + const std::string main = readTextFile(dir / "mygame" / "main.nut"); + CHECK(config.find("modules = [\"gfx\"]") != std::string::npos); + CHECK(main.find("persist boxX: float = 0.0") != std::string::npos); + CHECK(main.find("eve_update = function(dt: float)") != std::string::npos); + CHECK(main.find("getroottable") == std::string::npos); // A second run must not clobber existing files. { diff --git a/test/editor_pcg_graph.cpp b/test/editor_pcg_graph.cpp new file mode 100644 index 00000000..9488f383 --- /dev/null +++ b/test/editor_pcg_graph.cpp @@ -0,0 +1,228 @@ +#include "editor/EditorPcgGraph.h" +#include "procgen/PointGraph.h" + +#include "zeroerr/unittest.h" + +#include + +using namespace eve::editor; + +TEST_CASE("editor.pcgGraph.reflectsNodesAndCompilesPointGraphAsset") { + PcgPointGraphDomain domain; + auto input = domain.makeNode(GraphNodeId("input"), "input"); + auto prune = domain.makeNode(GraphNodeId("prune"), "self.prune"); + REQUIRE(input.accepted()); + REQUIRE(prune.accepted()); + REQUIRE(input.value); + REQUIRE(prune.value); + CHECK_EQ(input.value->pins.size(), size_t(1)); + CHECK_EQ(prune.value->pins.size(), size_t(2)); + const auto* properties = prune.value->properties.getIf(); + REQUIRE(bool(properties)); + CHECK_EQ(*properties->at("radius").getIf(), 1.0); + + GraphDocument graph; + CHECK(graph.createNode(*input.value).accepted()); + CHECK(graph.createNode(*prune.value).accepted()); + EditorValue::Object radiusBinding; + radiusBinding["node"] = EditorValue("prune"); + radiusBinding["key"] = EditorValue("radius"); + EditorValue::Object parameters; + parameters["pruneRadius"] = EditorValue(std::move(radiusBinding)); + CHECK(graph.setParameters(EditorValue(std::move(parameters))).accepted()); + const GraphPinRecord* from = graph.findPin(GraphPinId("input.out")); + const GraphPinRecord* to = graph.findPin(GraphPinId("prune.in0")); + REQUIRE(bool(from)); + REQUIRE(bool(to)); + CHECK(graph.connect({StableId("edge"), from->id, to->id}, domain.canConnect(*from, *to)) + .accepted()); + + const PcgGraphCompileResult compiled = domain.compile(graph.snapshot(domain.domain())); + CHECK_EQ(static_cast(compiled.status), static_cast(EditorStatus::Applied)); + CHECK(compiled.definition.find("EVPCG_POINT_GRAPH 1") == 0); + + eve::procgen::PointGraph runtime; + CHECK(runtime.deserializeDefinition(compiled.definition)); + CHECK_EQ(runtime.getParameterCount(), 1); + CHECK_EQ(runtime.getParameterName(0), std::string("pruneRadius")); + CHECK_EQ(runtime.getParameterKind("pruneRadius"), std::string("float")); + eve::procgen::PointSet points; + points.add(0.f, 0.f, 0.f); + points.add(0.5f, 0.f, 0.f); + CHECK(runtime.setNodePoints("input", &points)); + eve::procgen::PointSet* result = runtime.execute("prune"); + REQUIRE(bool(result)); + CHECK_EQ(result->getCount(), 1); + delete result; + + const auto preview = domain.preview(graph.snapshot(domain.domain()), "input", &points, + "prune"); + CHECK_EQ(static_cast(preview.status), static_cast(EditorStatus::Applied)); + CHECK_EQ(preview.outputCount, 1); + CHECK_EQ(preview.metrics.size(), size_t(2)); + const auto inputMetric = std::find_if( + preview.metrics.begin(), preview.metrics.end(), + [](const PcgGraphPreviewMetric& metric) { return metric.nodeId == "input"; }); + REQUIRE(inputMetric != preview.metrics.end()); + CHECK_EQ(inputMetric->outputCount, 2); + CHECK_EQ(inputMetric->minX, 0.f); + CHECK_EQ(inputMetric->maxX, 0.5f); + CHECK_EQ(inputMetric->averageDensity, 1.f); +} + +TEST_CASE("editor.pcgGraph.rejectsInvalidBlackboardParameterBindings") { + PcgPointGraphDomain domain; + auto node = domain.makeNode(GraphNodeId("move"), "transform"); + REQUIRE(node.value); + GraphDocument graph; + CHECK(graph.createNode(*node.value).accepted()); + const Revision before = graph.revision(); + CHECK(!graph.setParameters(EditorValue("not-an-object")).accepted()); + CHECK_EQ(graph.revision(), before); + + EditorValue::Object binding; + binding["node"] = EditorValue("move"); + binding["key"] = EditorValue("missing"); + EditorValue::Object parameters; + parameters["bad"] = EditorValue(std::move(binding)); + CHECK(graph.setParameters(EditorValue(std::move(parameters))).accepted()); + const auto compiled = domain.compile(graph.snapshot(domain.domain())); + CHECK_EQ(static_cast(compiled.status), static_cast(EditorStatus::Failed)); + REQUIRE(!compiled.diagnostics.empty()); + CHECK_EQ(compiled.diagnostics[0].rule.value(), + std::string("editor.pcg.invalid-parameter-binding")); +} + +TEST_CASE("editor.pcgGraph.rejectsCyclesAndWrongPinTypes") { + PcgPointGraphDomain domain; + auto a = domain.makeNode(GraphNodeId("a"), "jitter"); + auto b = domain.makeNode(GraphNodeId("b"), "self.prune"); + REQUIRE(a.value); + REQUIRE(b.value); + GraphDocument graph; + CHECK(graph.createNode(*a.value).accepted()); + CHECK(graph.createNode(*b.value).accepted()); + const auto* aOut = graph.findPin(GraphPinId("a.out")); + const auto* aIn = graph.findPin(GraphPinId("a.in0")); + const auto* bOut = graph.findPin(GraphPinId("b.out")); + const auto* bIn = graph.findPin(GraphPinId("b.in0")); + REQUIRE(bool(aOut)); + REQUIRE(bool(aIn)); + REQUIRE(bool(bOut)); + REQUIRE(bool(bIn)); + CHECK(graph.connect({StableId("ab"), aOut->id, bIn->id}, domain.canConnect(*aOut, *bIn)) + .accepted()); + CHECK(graph.connect({StableId("ba"), bOut->id, aIn->id}, domain.canConnect(*bOut, *aIn)) + .accepted()); + const auto compiled = domain.compile(graph.snapshot(domain.domain())); + CHECK_EQ(static_cast(compiled.status), static_cast(EditorStatus::Failed)); + CHECK(!compiled.diagnostics.empty()); + + GraphPinRecord wrong{GraphPinId("wrong"), GraphNodeId("x"), "float", + GraphPinDirection::Output}; + CHECK(!domain.canConnect(wrong, *aIn).allowed); + CHECK(!domain.makeNode(GraphNodeId("bad"), "missing").accepted()); +} + +TEST_CASE("editor.pcgGraph.strictlyValidatesSchemaAndNodeProperties") { + PcgPointGraphDomain domain; + auto node = domain.makeNode(GraphNodeId("move"), "transform"); + REQUIRE(node.value); + + GraphDocumentData unsupported; + unsupported.domain = domain.domain(); + unsupported.schemaVersion = 99; + auto result = domain.compile(unsupported); + CHECK_EQ(static_cast(result.status), static_cast(EditorStatus::Unsupported)); + REQUIRE(!result.diagnostics.empty()); + CHECK_EQ(result.diagnostics[0].rule.value(), std::string("editor.pcg.unsupported-schema")); + + GraphDocument graph; + CHECK(graph.createNode(*node.value).accepted()); + EditorValue::Object properties = *node.value->properties.getIf(); + properties["x"] = EditorValue("wrong-type"); + CHECK(graph.setNodeProperties(node.value->id, EditorValue(properties)).accepted()); + result = domain.compile(graph.snapshot(domain.domain())); + CHECK_EQ(static_cast(result.status), static_cast(EditorStatus::Failed)); + REQUIRE(!result.diagnostics.empty()); + CHECK_EQ(result.diagnostics[0].rule.value(), + std::string("editor.pcg.property-type-mismatch")); + + properties["x"] = EditorValue(0.0); + properties["typo"] = EditorValue(1.0); + CHECK(graph.setNodeProperties(node.value->id, EditorValue(properties)).accepted()); + result = domain.compile(graph.snapshot(domain.domain())); + CHECK_EQ(static_cast(result.status), static_cast(EditorStatus::Failed)); + REQUIRE(!result.diagnostics.empty()); + CHECK_EQ(result.diagnostics[0].rule.value(), std::string("editor.pcg.unknown-property")); +} + +TEST_CASE("editor.pcgGraph.transactionallyMigratesLegacyPinsAndDefaults") { + PcgPointGraphDomain domain; + auto input = domain.makeNode(GraphNodeId("source"), "input"); + auto prune = domain.makeNode(GraphNodeId("prune"), "self.prune"); + REQUIRE(input.value); + REQUIRE(prune.value); + input.value->pins[0].id = GraphPinId("legacy-source-output"); + prune.value->pins[0].id = GraphPinId("legacy-prune-input"); + prune.value->pins[1].id = GraphPinId("legacy-prune-output"); + prune.value->properties = EditorValue(EditorValue::Object{}); + + GraphDocumentData legacy; + legacy.domain = domain.domain(); + legacy.schemaVersion = 0; + legacy.revision = 17; + legacy.nodes = {*input.value, *prune.value}; + legacy.edges.push_back({StableId("stable-edge"), GraphPinId("legacy-source-output"), + GraphPinId("legacy-prune-input")}); + + const auto migrated = domain.migrate(legacy); + CHECK_EQ(static_cast(migrated.status), static_cast(EditorStatus::Applied)); + CHECK_EQ(migrated.fromVersion, uint32_t(0)); + CHECK_EQ(migrated.graph.schemaVersion, uint32_t(1)); + CHECK_EQ(migrated.graph.revision, Revision(17)); + REQUIRE_EQ(migrated.graph.nodes.size(), size_t(2)); + const auto* properties = migrated.graph.nodes[1].properties.getIf(); + REQUIRE(bool(properties)); + CHECK_EQ(*properties->at("radius").getIf(), 1.0); + REQUIRE_EQ(migrated.graph.edges.size(), size_t(1)); + CHECK_EQ(migrated.graph.edges[0].id.value(), std::string("stable-edge")); + CHECK_EQ(migrated.graph.edges[0].from.value(), std::string("source.out")); + CHECK_EQ(migrated.graph.edges[0].to.value(), std::string("prune.in0")); + + const auto compiled = domain.compile(legacy); + CHECK_EQ(static_cast(compiled.status), static_cast(EditorStatus::Applied)); + CHECK(compiled.definition.find("EVPCG_POINT_GRAPH 1") == 0); + REQUIRE(!compiled.diagnostics.empty()); + CHECK_EQ(compiled.diagnostics[0].rule.value(), std::string("editor.pcg.migrated-v0-v1")); + + GraphDocumentData invalid = legacy; + invalid.nodes[1].pins[0].id = GraphPinId("legacy-source-output"); + const auto rejected = domain.migrate(invalid); + CHECK_EQ(static_cast(rejected.status), static_cast(EditorStatus::Rejected)); + REQUIRE(!rejected.diagnostics.empty()); + CHECK_EQ(rejected.diagnostics[0].rule.value(), + std::string("editor.pcg.migration-duplicate-pin")); + CHECK_EQ(legacy.schemaVersion, uint32_t(0)); +} + +TEST_CASE("editor.pcgGraph.boundsIsolatedPreviewPointOutputs") { + PcgPointGraphDomain domain; + auto input = domain.makeNode(GraphNodeId("input"), "input"); + REQUIRE(input.value); + GraphDocument graph; + CHECK(graph.createNode(*input.value).accepted()); + eve::procgen::PointSet points; + for (int index = 0; index < 1001; ++index) points.add(float(index), 0.f, 0.f); + + const auto rejected = domain.preview(graph.snapshot(domain.domain()), "input", &points, + "input", 0, 1000); + CHECK_EQ(static_cast(rejected.status), static_cast(EditorStatus::Failed)); + REQUIRE(!rejected.diagnostics.empty()); + CHECK(rejected.diagnostics.back().message.find("point budget") != std::string::npos); + + const auto accepted = domain.preview(graph.snapshot(domain.domain()), "input", &points, + "input", 0, 1001); + CHECK_EQ(static_cast(accepted.status), static_cast(EditorStatus::Applied)); + CHECK_EQ(accepted.outputCount, 1001); +} diff --git a/test/gpgpu.cpp b/test/gpgpu.cpp index b98209b7..c64dcd78 100644 --- a/test/gpgpu.cpp +++ b/test/gpgpu.cpp @@ -32,11 +32,16 @@ #include "graphics/Volumetric.h" #include "graphics/Water.h" #include "graphics/Waterfall.h" +#include "procgen/PointCompute.h" +#include "procgen/PointGraph.h" #include "window/Window.h" #include +#include #include +#include #include +#include #include #include @@ -52,6 +57,23 @@ bool tryInitHeadlessGfx() { return true; } +void setPointComputeFailure(const char *stage) { +#ifdef _WIN32 + _putenv_s("EVENGINE_POINT_COMPUTE_FAIL", stage ? stage : ""); +#else + if (stage) + setenv("EVENGINE_POINT_COMPUTE_FAIL", stage, 1); + else + unsetenv("EVENGINE_POINT_COMPUTE_FAIL"); +#endif +} + +double medianMilliseconds(std::vector samples) { + std::sort(samples.begin(), samples.end()); + const size_t middle = samples.size() / 2; + return samples.size() % 2 ? samples[middle] : (samples[middle - 1] + samples[middle]) * 0.5; +} + const char *kScaleKernel = R"(#version 450 layout(local_size_x = 64) in; layout(set = 0, binding = 0) buffer Data { @@ -279,6 +301,247 @@ TEST_CASE("gpgpu.sequence.dispatchScale") { delete staging; } +TEST_CASE("gpgpu.procgen.transformParity") { + if (!tryInitHeadlessGfx()) return; + constexpr int count = 2048; + eve::procgen::PointSet input; + for (int index = 0; index < count; ++index) { + const int point = input.add(float(index % 64), float(index % 7), float(index / 64)); + input.setNormal(point, 0.25f, 1.f, -0.5f); + input.setYaw(point, float(index % 360)); + input.setScale(point, 0.5f, 1.5f, 2.f); + input.setDensity(point, float(index % 10) * 0.1f); + input.setPointSeed(point, uint32_t(index + 17)); + input.setStringAttribute(point, "asset", "oak"); + } + const eve::procgen::PointSet expected = + eve::procgen::transformPointSet(input, 12.f, -3.f, 8.f, 31.f, 1.25f, 0.75f, 2.f); + eve::procgen::PointSet actual; + eve::procgen::PointCompute compute; + REQUIRE(compute.transform(input, actual, 12.f, -3.f, 8.f, 31.f, 1.25f, 0.75f, 2.f)); + REQUIRE(actual.getCount() == expected.getCount()); + for (int index = 0; index < count; ++index) { + CHECK(std::fabs(actual.getX(index) - expected.getX(index)) < 0.0001f); + CHECK(std::fabs(actual.getY(index) - expected.getY(index)) < 0.0001f); + CHECK(std::fabs(actual.getZ(index) - expected.getZ(index)) < 0.0001f); + CHECK(std::fabs(actual.getNormalX(index) - expected.getNormalX(index)) < 0.0001f); + CHECK(std::fabs(actual.getNormalY(index) - expected.getNormalY(index)) < 0.0001f); + CHECK(std::fabs(actual.getNormalZ(index) - expected.getNormalZ(index)) < 0.0001f); + CHECK(actual.getPointSeed(index) == input.getPointSeed(index)); + CHECK(actual.getStringAttribute(index, "asset", "") == "oak"); + } + + const eve::procgen::PointSet reusedExpected = + eve::procgen::transformPointSet(input, -4.f, 6.f, 2.f, -17.f, 0.5f, 2.f, 1.25f); + eve::procgen::PointSet reusedActual; + REQUIRE(compute.transform(input, reusedActual, -4.f, 6.f, 2.f, -17.f, 0.5f, 2.f, 1.25f)); + REQUIRE(reusedActual.getCount() == reusedExpected.getCount()); + CHECK(std::fabs(reusedActual.getX(0) - reusedExpected.getX(0)) < 0.0001f); + CHECK(std::fabs(reusedActual.getZ(count - 1) - reusedExpected.getZ(count - 1)) < 0.0001f); + + const std::vector chain{ + {2.f, 3.f, -1.f, 15.f, 1.2f, 0.8f, 1.1f}, + {-7.f, 0.5f, 4.f, -33.f, 0.75f, 2.f, 0.6f}, + {1.f, -2.f, 8.f, 91.f, 1.5f, 1.25f, 0.9f}, + {0.f, 6.f, -3.f, 5.f, 0.5f, 0.5f, 2.f}, + }; + eve::procgen::PointSet fusedExpected = input; + for (const auto &operation : chain) + fusedExpected = + eve::procgen::transformPointSet(fusedExpected, operation.x, operation.y, operation.z, operation.yaw, + operation.scaleX, operation.scaleY, operation.scaleZ); + eve::procgen::PointSet fusedActual; + REQUIRE(compute.transformChain(input, fusedActual, chain)); + REQUIRE(fusedActual.getCount() == fusedExpected.getCount()); + CHECK_EQ(compute.getUploadCount(), uint64_t(3)); + CHECK_EQ(compute.getDispatchCount(), uint64_t(3)); + CHECK_EQ(compute.getReadbackCount(), uint64_t(3)); + CHECK_EQ(compute.getBufferReuseCount(), uint64_t(2)); + CHECK(compute.getPeakBufferBytes() >= uint64_t(count * 11 * int(sizeof(float)))); + CHECK_EQ(compute.getLastFusedTransformCount(), 4); + for (int index : {0, count / 2, count - 1}) { + CHECK(std::fabs(fusedActual.getX(index) - fusedExpected.getX(index)) < 0.0002f); + CHECK(std::fabs(fusedActual.getY(index) - fusedExpected.getY(index)) < 0.0002f); + CHECK(std::fabs(fusedActual.getZ(index) - fusedExpected.getZ(index)) < 0.0002f); + CHECK(std::fabs(fusedActual.getNormalX(index) - fusedExpected.getNormalX(index)) < 0.0002f); + CHECK(std::fabs(fusedActual.getNormalY(index) - fusedExpected.getNormalY(index)) < 0.0002f); + CHECK(std::fabs(fusedActual.getNormalZ(index) - fusedExpected.getNormalZ(index)) < 0.0002f); + } +} + +TEST_CASE("gpgpu.procgen.transformChainRejectsInvalidLength") { + eve::procgen::PointSet input; + input.add(1.f, 2.f, 3.f); + eve::procgen::PointSet output; + eve::procgen::PointCompute compute; + const std::vector empty; + CHECK(!compute.transformChain(input, output, empty)); + CHECK(output.empty()); + CHECK_EQ(compute.getDispatchCount(), uint64_t(0)); + + const std::vector oversized(5); + CHECK(!compute.transformChain(input, output, oversized)); + CHECK(output.empty()); + CHECK_EQ(compute.getDispatchCount(), uint64_t(0)); +} + +TEST_CASE("gpgpu.procgen.pointGraphCompilesLinearTransformSegment") { + if (!tryInitHeadlessGfx()) return; + constexpr int count = 2048; + eve::procgen::PointSet input; + for (int index = 0; index < count; ++index) input.add(float(index), 2.f, -3.f); + + eve::procgen::PointGraph graph; + REQUIRE(graph.addNode("source", "input")); + REQUIRE(graph.setNodePoints("source", &input)); + std::string previous = "source"; + for (int index = 0; index < 4; ++index) { + const std::string id = "move-" + std::to_string(index); + REQUIRE(graph.addNode(id, "transform")); + REQUIRE(graph.connect(previous, id)); + REQUIRE(graph.setNodeFloat(id, "x", float(index + 1))); + previous = id; + } + REQUIRE(graph.setComputePolicy("gpu")); + std::unique_ptr output(graph.execute(previous)); + REQUIRE(bool(output)); + CHECK_EQ(graph.getComputeUploadCount(), uint64_t(1)); + CHECK_EQ(graph.getComputeDispatchCount(), uint64_t(1)); + CHECK_EQ(graph.getComputeReadbackCount(), uint64_t(1)); + CHECK_EQ(graph.getLastFusedTransformCount(), 4); + CHECK(std::fabs(output->getX(0) - 10.f) < 0.0001f); + + std::unique_ptr preview(graph.getNodeOutput("move-1")); + REQUIRE(bool(preview)); + CHECK(std::fabs(preview->getX(0) - 3.f) < 0.0001f); + CHECK_EQ(graph.getComputeReadbackCount(), uint64_t(1)); + + std::unique_ptr cached(graph.execute(previous)); + REQUIRE(bool(cached)); + CHECK_EQ(graph.getComputeDispatchCount(), uint64_t(1)); + CHECK(graph.getCacheHitCount() > 0); +} + +TEST_CASE("gpgpu.procgen.pointGraphFallsBackAtomicallyAfterForcedGpuFailures") { + if (!tryInitHeadlessGfx()) return; + for (const char *stage : {"compile", "allocation", "submit", "readback"}) { + eve::procgen::PointSet input; + for (int index = 0; index < 2048; ++index) input.add(float(index), 0.f, 0.f); + eve::procgen::PointGraph graph; + REQUIRE(graph.addNode("source", "input")); + REQUIRE(graph.addNode("first", "transform")); + REQUIRE(graph.addNode("second", "transform")); + REQUIRE(graph.connect("source", "first")); + REQUIRE(graph.connect("first", "second")); + REQUIRE(graph.setNodePoints("source", &input)); + REQUIRE(graph.setNodeFloat("first", "x", 2.f)); + REQUIRE(graph.setNodeFloat("second", "x", 3.f)); + REQUIRE(graph.setComputePolicy("gpu")); + + setPointComputeFailure(stage); + std::unique_ptr output(graph.execute("second")); + setPointComputeFailure(nullptr); + REQUIRE(bool(output)); + CHECK_EQ(output->getX(0), 5.f); + CHECK(graph.getComputeFallbackReason().find("forced") != std::string::npos); + CHECK_EQ(graph.getComputeUploadCount(), uint64_t(0)); + CHECK_EQ(graph.getComputeDispatchCount(), uint64_t(0)); + CHECK_EQ(graph.getComputeReadbackCount(), uint64_t(0)); + std::unique_ptr intermediate(graph.getNodeOutput("first")); + REQUIRE(bool(intermediate)); + CHECK_EQ(intermediate->getX(0), 2.f); + } +} + +TEST_CASE("gpgpu.procgen.millionPointAcceptanceBenchmark") { + if (!std::getenv("EVENGINE_PCG_MILLION_BENCHMARK") || !tryInitHeadlessGfx()) return; + constexpr int count = 1000000; + const std::vector chain{ + {2.f, 3.f, -1.f, 15.f, 1.2f, 0.8f, 1.1f}, + {-7.f, 0.5f, 4.f, -33.f, 0.75f, 2.f, 0.6f}, + {1.f, -2.f, 8.f, 91.f, 1.5f, 1.25f, 0.9f}, + {0.f, 6.f, -3.f, 5.f, 0.5f, 0.5f, 2.f}, + }; + eve::procgen::PointSet input; + for (int index = 0; index < count; ++index) input.add(float(index % 1000), float(index % 31), float(index / 1000)); + + const auto measure = [](auto &&operation) { + const auto started = std::chrono::steady_clock::now(); + operation(); + return std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + }; + auto cpuRun = [&] { + eve::procgen::PointSet output = input; + for (const auto &transform : chain) + output = eve::procgen::transformPointSet(output, transform.x, transform.y, transform.z, transform.yaw, + transform.scaleX, transform.scaleY, transform.scaleZ); + REQUIRE(output.getCount() == count); + }; + eve::procgen::PointGraph fusedGraph; + REQUIRE(fusedGraph.addNode("source", "input")); + REQUIRE(fusedGraph.setNodePoints("source", &input)); + std::string previous = "source"; + for (int index = 0; index < 4; ++index) { + const std::string id = "move-" + std::to_string(index); + const auto &transform = chain[size_t(index)]; + REQUIRE(fusedGraph.addNode(id, "transform")); + REQUIRE(fusedGraph.connect(previous, id)); + REQUIRE(fusedGraph.setNodeFloat(id, "x", transform.x)); + REQUIRE(fusedGraph.setNodeFloat(id, "y", transform.y)); + REQUIRE(fusedGraph.setNodeFloat(id, "z", transform.z)); + REQUIRE(fusedGraph.setNodeFloat(id, "yaw", transform.yaw)); + REQUIRE(fusedGraph.setNodeFloat(id, "scaleX", transform.scaleX)); + REQUIRE(fusedGraph.setNodeFloat(id, "scaleY", transform.scaleY)); + REQUIRE(fusedGraph.setNodeFloat(id, "scaleZ", transform.scaleZ)); + previous = id; + } + REQUIRE(fusedGraph.setComputePolicy("gpu")); + auto fusedRun = [&] { + fusedGraph.clearCache(); + std::unique_ptr output(fusedGraph.execute(previous)); + REQUIRE(bool(output)); + REQUIRE(output->getCount() == count); + }; + eve::procgen::PointCompute isolatedCompute; + auto isolatedRun = [&] { + eve::procgen::PointSet current = input; + for (const auto &transform : chain) { + eve::procgen::PointSet output; + REQUIRE(isolatedCompute.transform(current, output, transform.x, transform.y, transform.z, transform.yaw, + transform.scaleX, transform.scaleY, transform.scaleZ)); + current = std::move(output); + } + }; + + for (int warmup = 0; warmup < 5; ++warmup) { + fusedRun(); + isolatedRun(); + } + cpuRun(); + std::vector cpu, fused, isolated; + for (int iteration = 0; iteration < 20; ++iteration) { + cpu.push_back(measure(cpuRun)); + fused.push_back(measure(fusedRun)); + isolated.push_back(measure(isolatedRun)); + } + const double cpuMedian = medianMilliseconds(cpu); + const double fusedMedian = medianMilliseconds(fused); + const double isolatedMedian = medianMilliseconds(isolated); + CHECK(cpuMedian / fusedMedian >= 2.0); + CHECK(isolatedMedian / fusedMedian >= 1.5); + CHECK_EQ(fusedGraph.getComputeUploadCount(), uint64_t(25)); + CHECK_EQ(fusedGraph.getComputeDispatchCount(), uint64_t(25)); + CHECK_EQ(fusedGraph.getComputeReadbackCount(), uint64_t(25)); + CHECK_EQ(isolatedCompute.getUploadCount(), uint64_t(100)); + CHECK_EQ(isolatedCompute.getDispatchCount(), uint64_t(100)); + CHECK_EQ(isolatedCompute.getReadbackCount(), uint64_t(100)); + std::cout << "PCG_BENCHMARK_JSON={\"points\":" << count << ",\"operations\":4,\"samples\":20," + << "\"cpuMedianMs\":" << cpuMedian << ",\"fusedGpuMedianMs\":" << fusedMedian + << ",\"isolatedGpuMedianMs\":" << isolatedMedian << ",\"cpuSpeedup\":" << cpuMedian / fusedMedian + << ",\"roundTripSpeedup\":" << isolatedMedian / fusedMedian << "}\n"; + CHECK_EQ(fusedGraph.getLastFusedTransformCount(), 4); +} + TEST_CASE("gpgpu.sequence.singleSubmitChainedDispatches") { if (!tryInitHeadlessGfx()) return; auto *mod = Gpgpu::create(); diff --git a/test/hd2d.cpp b/test/hd2d.cpp new file mode 100644 index 00000000..6201ef47 --- /dev/null +++ b/test/hd2d.cpp @@ -0,0 +1,317 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" +#include "Fixtures.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "filesystem/FileData.h" +#include "graphics/Graphics.h" +#include "graphics/Material.h" +#include "graphics/Mesh.h" +#include "graphics/RenderSystem3D.h" +#include "graphics/Texture.h" +#include "hd2d/Hd2d.h" +#include "image/Image.h" +#include "image/ImageData.h" +#include "map/Map.h" +#include "map/TileLayer.h" + +using eve::graphics::Color; +using eve::graphics::Graphics; +using eve::graphics::Mesh; +using eve::graphics::Texture; +using eve::hd2d::Hd2D; +using eve::hd2d::Sprite3D; +using eve::hd2d::TileMap3D; +using eve::map::Map; +using eve::map::TileLayer; + +namespace { + +std::string testOutDir() { return std::string(EVENGINE_TEST_BINARY_DIR) + "/out"; } + +void savePng(eve::image::ImageData *frame, const std::string &name) { + REQUIRE(frame != nullptr); + eve::image::Image::create(); + std::unique_ptr png( + frame->encode(eve::image::ImageData::FormatHandler::ENCODED_PNG, name.c_str(), false)); + REQUIRE(png.get() != nullptr); + std::error_code ec; + std::filesystem::create_directories(std::filesystem::path(testOutDir()), ec); + std::ofstream out(std::filesystem::path(testOutDir()) / name, std::ios::binary); + out.write(static_cast(png->getData()), std::streamsize(png->getSize())); + REQUIRE(out.good()); +} + +void resetScene3D() { + 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; + } + } +} + +Texture *makeChecker(Graphics *gfx, int size, int cell) { + std::vector px(size_t(size) * size_t(size) * 4); + for (int y = 0; y < size; ++y) { + for (int x = 0; x < size; ++x) { + uint8_t c = ((x / cell) ^ (y / cell)) & 1 ? 255 : 0; + size_t i = size_t(y * size + x) * 4; + px[i] = c; + px[i + 1] = c; + px[i + 2] = c; + px[i + 3] = 255; + } + } + return gfx->newTexture(size, size, px.data(), eve::graphics::TextureCreateInfo::withMipmaps(true)); +} + +/** @brief Fills a 4x3 layer with two GIDs from a 4-column checker tileset. */ +TileLayer *makeTilesetLayer(Graphics *gfx) { + TileLayer *layer = Map::create()->newLayer(4, 3, 32.f, 32.f); + Texture *atlas = makeChecker(gfx, 128, 8); + layer->setTileset(atlas, 1, 4, 0, 0); + layer->setTilesetTileSize(32, 32); + for (int ty = 0; ty < 3; ++ty) + for (int tx = 0; tx < 4; ++tx) layer->setTile(tx, ty, 1 + ((tx + ty) % 4)); + return layer; +} + +} // namespace + +TEST_CASE("hd2d.tilemap.buildMeshBoxCounts") { + GfxFixture fx; + TileLayer *layer = makeTilesetLayer(fx.gfx); + TileMap3D t; + t.setSideDepth(4.f); + Mesh *mesh = t.buildMesh(fx.gfx, layer); + REQUIRE(mesh != nullptr); + // 12 filled tiles, 24 verts + 36 indices per box. + CHECK_EQ(t.getTileCount(), 12); + CHECK_EQ(mesh->indexCount, 12 * 36); + CHECK_EQ(mesh->gpuVertexCount, 12 * 24); + CHECK(mesh->hasBounds()); +} + +TEST_CASE("hd2d.tilemap.heightRaisesBoxes") { + GfxFixture fx; + TileLayer *layer = makeTilesetLayer(fx.gfx); + layer->setTileDataNumber(1, "height", 3.f); + TileMap3D t; + t.setHeightScale(2.f); + Mesh *low = t.buildMesh(fx.gfx, layer); + REQUIRE(low != nullptr); + CHECK_GT(low->boundsCy, 0.f); + TileMap3D t2; + t2.setHeightScale(0.f); + Mesh *flat = t2.buildMesh(fx.gfx, layer); + REQUIRE(flat != nullptr); + CHECK_LT(flat->boundsCy, low->boundsCy); +} + +TEST_CASE("hd2d.tilemap.emptyLayerYieldsNullMesh") { + GfxFixture fx; + TileLayer *layer = Map::create()->newLayer(3, 3, 32.f, 32.f); + TileMap3D t; + CHECK(t.buildMesh(fx.gfx, layer) == nullptr); + CHECK_EQ(t.getTileCount(), 0); +} + +TEST_CASE("hd2d.tilemap.buildRenderableDraws") { + GfxFixture fx; + resetScene3D(); + TileLayer *layer = makeTilesetLayer(fx.gfx); + TileMap3D t; + t.setSideDepth(4.f); + auto *renderable = t.buildRenderable(fx.gfx, layer); + REQUIRE(renderable != nullptr); + REQUIRE(renderable->getMesh() != nullptr); + + auto *cam = eve::graphics::Camera3D::createCamera(); + cam->setEye(64.f, 40.f, 110.f); + cam->setTarget(64.f, 0.f, 48.f); + eve::graphics::RenderSystem3D::setDirectionalLight(0.4f, 1.f, 0.3f, 1.f, 1.f, 1.f); + fx.gfx->setScreenReadbackEnabled(true); + fx.gfx->setBackgroundColor(Color(0.06f, 0.08f, 0.10f, 1.f)); + + for (int i = 0; i < 3; ++i) { + fx.gfx->clear(std::nullopt, std::nullopt, std::nullopt); + eve::graphics::RenderSystem3D::render(*fx.gfx); + fx.gfx->present(); + } + auto frame = std::unique_ptr(fx.gfx->newImageData()); + REQUIRE(frame.get() != nullptr); + savePng(frame.get(), "hd2d_tilemap3d.png"); + int changed = 0; + for (int y = 0; y < frame->getHeight(); y += 2) + for (int x = 0; x < frame->getWidth(); x += 2) { + const auto p = frame->getPixel(x, y); + if (std::fabs(p.r - 0.06f) + std::fabs(p.g - 0.08f) + std::fabs(p.b - 0.10f) > 0.3f) + ++changed; + } + CHECK_GT(changed, 50); +} + +TEST_CASE("hd2d.scene.tilemapPlusBillboard") { + GfxFixture fx; + resetScene3D(); + + // HD-2D ground: a 12x8 tile layer extruded into 3D blocks with checkered tops. + TileLayer *layer = Map::create()->newLayer(12, 8, 32.f, 32.f); + Texture *atlas = makeChecker(fx.gfx, 128, 8); + layer->setTileset(atlas, 1, 4, 0, 0); + layer->setTilesetTileSize(32, 32); + for (int ty = 0; ty < 8; ++ty) + for (int tx = 0; tx < 12; ++tx) + layer->setTile(tx, ty, 1 + ((tx / 2 + ty / 2) % 4)); + layer->setTileDataNumber(1, "height", 1.f); + layer->setTileDataNumber(2, "height", 2.f); + TileMap3D ground; + ground.setSideDepth(6.f); + ground.setHeightScale(8.f); + ground.buildRenderable(fx.gfx, layer); + + // A billboard "character" standing on the terrain. + auto *hero = Hd2D::create()->newSprite(fx.gfx); + REQUIRE(hero != nullptr); + hero->setTexture(makeChecker(fx.gfx, 16, 4)); + hero->setFrameGrid(1, 1); + hero->setPosition(32.f * 6.f, 16.f, 32.f * 4.f); + hero->setSize(28.f, 56.f); + + auto *cam = eve::graphics::Camera3D::createCamera(); + cam->setEye(192.f, 40.f, 190.f); + cam->setTarget(192.f, 0.f, 128.f); + hero->setCamera(cam); + eve::graphics::RenderSystem3D::setDirectionalLight(0.4f, 1.f, 0.3f, 1.f, 1.f, 1.f); + fx.gfx->setScreenReadbackEnabled(true); + fx.gfx->setBackgroundColor(Color(0.06f, 0.08f, 0.10f, 1.f)); + + for (int i = 0; i < 4; ++i) { + hero->update(0.05f); + fx.gfx->clear(std::nullopt, std::nullopt, std::nullopt); + eve::graphics::RenderSystem3D::render(*fx.gfx); + fx.gfx->present(); + } + auto frame = std::unique_ptr(fx.gfx->newImageData()); + REQUIRE(frame.get() != nullptr); + savePng(frame.get(), "hd2d_scene_tilemap_plus_billboard.png"); + // Count checker-white pixels (the tilemap tops and the hero billboard). + int bright = 0; + for (int y = 0; y < frame->getHeight(); y += 2) + for (int x = 0; x < frame->getWidth(); x += 2) { + const auto p = frame->getPixel(x, y); + if (p.r + p.g + p.b > 1.2f) ++bright; + } + CHECK_GT(bright, 100); +} + +TEST_CASE("hd2d.sprite.frameGridAnimation") { + GfxFixture fx; + auto *sprite = Hd2D::create()->newSprite(fx.gfx); + REQUIRE(sprite != nullptr); + sprite->setTexture(makeChecker(fx.gfx, 64, 8)); + sprite->setFrameGrid(4, 2); + CHECK_EQ(sprite->getFrameCount(), 8); + CHECK_EQ(sprite->getFrameIndex(), 0); + + sprite->play(2, 5, 4.f); // 4 frames at 4fps -> 0.25s each + CHECK(sprite->isPlaying()); + sprite->update(0.75f); // 3 steps -> frame 5 + CHECK_EQ(sprite->getFrameIndex(), 5); + sprite->update(0.26f); // wrap to start of range + CHECK_EQ(sprite->getFrameIndex(), 2); + sprite->stop(); + CHECK(!sprite->isPlaying()); + sprite->update(10.f); + CHECK_EQ(sprite->getFrameIndex(), 2); + fx.gfx->clear(std::nullopt, std::nullopt, std::nullopt); // flush GPU ring before teardown + fx.gfx->present(); +} + +TEST_CASE("hd2d.sprite.flipSwapsFrameUvs") { + GfxFixture fx; + auto *sprite = Hd2D::create()->newSprite(fx.gfx); + REQUIRE(sprite != nullptr); + sprite->setTexture(makeChecker(fx.gfx, 16, 4)); + sprite->setFrameGrid(2, 1); + sprite->setFrameIndex(1); + float u0, v0, u1, v1; + sprite->getFrame(u0, v0, u1, v1); + CHECK(u0 > 0.49f); + sprite->setFlipX(true); + sprite->getFrame(u0, v0, u1, v1); + CHECK(u0 < 0.01f); + fx.gfx->clear(std::nullopt, std::nullopt, std::nullopt); // flush GPU ring before teardown + fx.gfx->present(); +} + +TEST_CASE("hd2d.sprite.renderAsBillboard") { + GfxFixture fx; + resetScene3D(); + auto *sprite = Hd2D::create()->newSprite(fx.gfx); + REQUIRE(sprite != nullptr); + sprite->setTexture(makeChecker(fx.gfx, 16, 4)); + sprite->setFrameGrid(2, 2); + sprite->setPosition(0.f, 0.f, 0.f); + sprite->setSize(4.f, 4.f); + + auto *cam = eve::graphics::Camera3D::createCamera(); + cam->setEye(0.f, 2.f, 6.f); + cam->setTarget(0.f, 0.f, 0.f); + sprite->setCamera(cam); + eve::graphics::RenderSystem3D::setDirectionalLight(0.4f, 1.f, 0.3f, 1.f, 1.f, 1.f); + fx.gfx->setScreenReadbackEnabled(true); + fx.gfx->setBackgroundColor(Color(0.05f, 0.05f, 0.05f, 1.f)); + + for (int i = 0; i < 3; ++i) { + sprite->update(0.05f); + fx.gfx->clear(std::nullopt, std::nullopt, std::nullopt); + eve::graphics::RenderSystem3D::render(*fx.gfx); + fx.gfx->present(); + } + auto frame = std::unique_ptr(fx.gfx->newImageData()); + REQUIRE(frame.get() != nullptr); + savePng(frame.get(), "hd2d_billboard.png"); + int bright = 0; + for (int y = 0; y < frame->getHeight(); y += 1) + for (int x = 0; x < frame->getWidth(); x += 1) { + const auto p = frame->getPixel(x, y); + if (p.r + p.g + p.b > 0.6f) ++bright; + } + // A camera-facing billboard must be clearly visible: plenty of bright sprite + // pixels forming a roughly centred quad. + REQUIRE_GT(bright, 30); + int hBright = 0; + for (int x = 0; x < frame->getWidth(); ++x) { + const auto p = frame->getPixel(x, frame->getHeight() / 2); + if (p.r + p.g + p.b > 0.6f) ++hBright; + } + // A camera-facing quad spans the mid-row as a horizontal band (not a sliver). + REQUIRE_GT(hBright, 8); +} + +TEST_CASE("hd2d.sprite.quadMeshSharedUpload") { + GfxFixture fx; + auto *sprite = Hd2D::create()->newSprite(fx.gfx); + REQUIRE(sprite != nullptr); + REQUIRE(sprite->quadMesh() != nullptr); + CHECK_EQ(sprite->quadMesh()->indexCount, 6); + CHECK_EQ(sprite->quadMesh()->gpuVertexCount, 4); +} diff --git a/test/language_index.cpp b/test/language_index.cpp new file mode 100644 index 00000000..86d0cdef --- /dev/null +++ b/test/language_index.cpp @@ -0,0 +1,83 @@ +#include "cmdline/LanguageIndex.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include + +using eve::cmd::lsp::Position; +using eve::cmd::lsp::WorkspaceIndex; + +TEST_CASE("languageIndex.crossFileDefinitionAndReferences") { + WorkspaceIndex index; + index.update("game:/lib.nut", "file:///game/lib.nut", + "export function greet(name: string) -> string { return name }\n"); + index.update("game:/main.nut", "file:///game/main.nut", + "import { greet } from \"./lib.nut\"\n" + "local message = greet(\"Ada\")\n"); + + const auto definition = index.definition("game:/main.nut", Position{1, 17}); + REQUIRE(definition.has_value()); + CHECK_EQ(definition->uri, std::string("file:///game/lib.nut")); + CHECK_EQ(definition->range.start.line, size_t(0)); + CHECK_EQ(definition->range.start.character, size_t(16)); + + const auto references = index.references("game:/lib.nut", Position{0, 17}, true); + CHECK_EQ(references.size(), size_t(3)); + const auto usages = index.references("game:/lib.nut", Position{0, 17}, false); + CHECK_EQ(usages.size(), size_t(2)); +} + +TEST_CASE("languageIndex.renameExportUpdatesImportAndUsages") { + WorkspaceIndex index; + index.update("game:/lib.nut", "file:///game/lib.nut", "export const SPEED = 2.0\n"); + index.update("game:/main.nut", "file:///game/main.nut", + "import { SPEED } from \"./lib.nut\"\nlocal value = SPEED\n"); + + const auto edits = index.rename("game:/lib.nut", Position{0, 14}, "PLAYER_SPEED"); + REQUIRE(edits.has_value()); + CHECK_EQ(edits->size(), size_t(3)); + for (const auto& edit : *edits) CHECK_EQ(edit.newText, std::string("PLAYER_SPEED")); +} + +TEST_CASE("languageIndex.renameImportAliasStaysLocal") { + WorkspaceIndex index; + index.update("game:/lib.nut", "file:///game/lib.nut", "export function greet() {}\n"); + index.update("game:/main.nut", "file:///game/main.nut", + "import { greet as salute } from \"./lib.nut\"\nsalute()\n"); + + const auto definition = index.definition("game:/main.nut", Position{1, 2}); + REQUIRE(definition.has_value()); + CHECK_EQ(definition->uri, std::string("file:///game/lib.nut")); + + const auto edits = index.rename("game:/main.nut", Position{1, 2}, "hello"); + REQUIRE(edits.has_value()); + CHECK_EQ(edits->size(), size_t(2)); + for (const auto& edit : *edits) CHECK_EQ(edit.location.uri, std::string("file:///game/main.nut")); +} + +TEST_CASE("languageIndex.namespaceImportSupportsDefinitionReferencesAndRename") { + WorkspaceIndex index; + index.update("game:/lib.nut", "file:///game/lib.nut", "export function greet() {}\n"); + index.update("game:/namespace.nut", "file:///game/namespace.nut", + "import * as api from \"./lib.nut\"\napi.greet()\n"); + index.update("game:/named.nut", "file:///game/named.nut", "import { greet } from \"./lib.nut\"\ngreet()\n"); + + const auto definition = index.definition("game:/namespace.nut", Position{1, 6}); + REQUIRE(definition.has_value()); + CHECK_EQ(definition->uri, std::string("file:///game/lib.nut")); + + const auto references = index.references("game:/namespace.nut", Position{1, 6}, true); + CHECK_EQ(references.size(), size_t(4)); + + const auto edits = index.rename("game:/namespace.nut", Position{1, 6}, "welcome"); + REQUIRE(edits.has_value()); + CHECK_EQ(edits->size(), size_t(4)); + for (const auto& edit : *edits) CHECK_EQ(edit.newText, std::string("welcome")); +} + +TEST_CASE("languageIndex.rejectsInvalidRename") { + WorkspaceIndex index; + index.update("game:/main.nut", "file:///game/main.nut", "local value = 1\n"); + CHECK(!index.rename("game:/main.nut", Position{0, 7}, "not-valid").has_value()); +} diff --git a/test/procgen_biome.cpp b/test/procgen_biome.cpp new file mode 100644 index 00000000..ce48e992 --- /dev/null +++ b/test/procgen_biome.cpp @@ -0,0 +1,59 @@ +#include "procgen/Biome.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::procgen; + +TEST_CASE("procgen.biome.appliesPriorityExclusionAndAssetMetadata") { + BiomeRules rules; + SpatialData domain = SpatialData::box(0.f, 0.f, 0.f, 4.f, 0.f, 4.f); + SpatialData forest = domain; + SpatialData town = SpatialData::sphere(2.f, 0.f, 2.f, 1.1f); + SpatialData noSpawn = SpatialData::sphere(0.f, 0.f, 0.f, 0.5f); + CHECK(rules.addLayer("forest", &forest, 1, 1.f)); + CHECK(rules.addLayer("town", &town, 10, 1.f)); + CHECK(!rules.addLayer("forest", &forest, 2, 1.f)); + CHECK(rules.addAsset("forest", "oak", 3.f, 0.8f, 1.2f, true)); + CHECK(rules.addAsset("forest", "pine", 1.f, 1.f, 1.f, true)); + CHECK(rules.addAsset("town", "lamp", 1.f, 1.f, 1.f, false)); + CHECK(rules.addExclusion(&noSpawn)); + + PointSet* first = rules.generate(&domain, 2.f, 42, 0.f); + PointSet* second = rules.generate(&domain, 2.f, 42, 0.f); + REQUIRE(bool(first)); + REQUIRE(bool(second)); + CHECK_EQ(first->getCount(), 8); + CHECK_EQ(second->getCount(), first->getCount()); + int townCount = 0; + for (int i = 0; i < first->getCount(); ++i) { + CHECK_EQ(first->getStringAttribute(i, "asset", ""), + second->getStringAttribute(i, "asset", "")); + CHECK_EQ(first->getYaw(i), second->getYaw(i)); + CHECK_EQ(first->getScaleX(i), second->getScaleX(i)); + if (first->getStringAttribute(i, "biome", "") == "town") { + ++townCount; + CHECK_EQ(first->getStringAttribute(i, "asset", ""), std::string("lamp")); + CHECK_EQ(first->getYaw(i), 0.f); + } + } + CHECK_EQ(townCount, 1); + CHECK(rules.debugReport().find("output=8") != std::string::npos); + + delete second; + delete first; +} + +TEST_CASE("procgen.biome.validatesRulesAndEmptyDensity") { + BiomeRules rules; + SpatialData domain = SpatialData::box(0.f, 0.f, 0.f, 2.f, 0.f, 2.f); + CHECK(!rules.generate(&domain, 1.f, 1, 0.f)); + CHECK(rules.getError().find("no biome layers") != std::string::npos); + CHECK(rules.addLayer("empty", &domain, 0, 0.f)); + CHECK(!rules.addAsset("missing", "asset", 1.f, 1.f, 1.f, true)); + CHECK(rules.addAsset("empty", "asset", 1.f, 1.f, 1.f, true)); + PointSet* result = rules.generate(&domain, 1.f, 1, 0.f); + REQUIRE(bool(result)); + CHECK_EQ(result->getCount(), 0); + delete result; +} diff --git a/test/procgen_lsystem.cpp b/test/procgen_lsystem.cpp new file mode 100644 index 00000000..6c8c574c --- /dev/null +++ b/test/procgen_lsystem.cpp @@ -0,0 +1,233 @@ +#include "procgen/PointSet.h" +#include "procgen/Procgen.h" +#include "procgen/algorithms/LSystem.h" + +#include "graphics/Graphics.h" +#include "graphics/Mesh.h" +#include "graphics/RenderSystem.h" +#include "graphics/RenderSystem3D.h" +#include "graphics/Texture.h" +#include "image/Image.h" +#include "window/Window.h" +#include "RenderImageAudit.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include +#include + +using namespace eve::procgen; +using namespace eve::graphics; + +TEST_CASE("procgen.lsystem.renderDump") { + const char *outputPath = std::getenv("EVENGINE_LSYSTEM_RENDER_PNG"); + if (!outputPath || !outputPath[0]) return; + + auto *win = eve::window::Window::create(); + auto *gfx = Graphics::create(); + REQUIRE(win != nullptr); + REQUIRE(gfx != nullptr); + eve::window::WindowSettings ws; + ws.width = 900; + ws.height = 700; + ws.centered = true; + REQUIRE(win->setWindowSettings(ws)); + + Params params; + params.setSeed(20260826); + const char *styleOverride = std::getenv("EVENGINE_LSYSTEM_STYLE"); + params.setString("style", styleOverride ? styleOverride : "tree"); + params.setInt("iterations", 5); + params.setString("leafMode", "cards"); + params.setFloat("tropism", 0.05f); + params.setFloat("leafSize", 0.5f); + + Procgen generator; + auto paramsResult = Procgen::newParamsHandle(); + REQUIRE(paramsResult.ok()); + auto paramsHandle = std::move(paramsResult).takeValue(); + auto paramsView = Procgen::resolve(paramsHandle); + REQUIRE(paramsView.isBound()); + *paramsView = params; + auto lsMeshView = generator.generateMeshBorrowed("mesh.lsystem", paramsHandle, gfx); + REQUIRE(lsMeshView.isBound()); + Mesh *lsMesh = lsMeshView.get(); + + // 4px bark/foliage atlas; UVs are partitioned by the mesh recipe (left = bark). + 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 *plant = Renderable3D::create(); + plant->setMesh(lsMesh); + plant->setTexture(atlas); + plant->setTint(1.f, 1.f, 1.f, 1.f); + plant->setRoughness(0.88f); + + auto *camera = Camera3D::createCamera(); + camera->setEye(7.5f, 5.2f, 9.5f); + camera->setTarget(0.f, 3.2f, 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); + + 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("lsystem render saved: %s\n", outputPath); + REQUIRE(Procgen::release(paramsHandle).ok()); + win->close(); +} + +TEST_CASE("procgen.lsystem.deterministicExpansion") { + LSystem ls; + ls.setAxiom("F"); + ls.addRule('F', "F[+F]F[-F]F"); + ls.setIterations(3); + ls.setSeed(42); + + const std::string a = ls.derive(); + const std::string b = ls.derive(); + CHECK(!a.empty()); + CHECK_EQ(a, b); + + // More iterations must grow the word monotonically. + ls.setIterations(4); + const std::string c = ls.derive(); + CHECK(c.size() > a.size()); +} + +TEST_CASE("procgen.lsystem.stochasticRulesUseSeed") { + LSystem ls; + ls.setAxiom("A"); + // Branching grammar so different seeds produce a rich, distinct outcome space. + ls.addRules('A', {"F[+A]A", "F[-A]A", "FA"}, {1.f, 1.f, 1.f}); + ls.setIterations(4); + + // Determinism for a fixed seed. + ls.setSeed(7); + const std::string a = ls.derive(); + ls.setSeed(7); + const std::string b = ls.derive(); + CHECK_EQ(a, b); + CHECK(!a.empty()); + + // Across several seeds the stochastic grammar should not all collapse to one. + std::set seen; + for (int s = 1; s <= 8; ++s) { + ls.setSeed(s); + seen.insert(ls.derive()); + } + CHECK(seen.size() >= 2); +} + +TEST_CASE("procgen.lsystem.turtleProducesSegments") { + LSystem ls; + ls.setAxiom("F[+F]F[-F]F"); + ls.addRule('F', "F"); + ls.setIterations(1); + ls.setAngle(25.f); + ls.setStep(1.f); + ls.setSeed(1); + + LSystemResult result; + ls.generate(result); + CHECK(!result.segments.empty()); + CHECK(!result.derivation.empty()); + + // Bracketed depth increases then returns: trunk segment is at depth 0. + bool sawDepthZero = false; + bool sawDeep = false; + for (const auto& seg : result.segments) { + if (seg.depth == 0) sawDepthZero = true; + if (seg.depth > 0) sawDeep = true; + if (seg.leaf) CHECK(seg.leafSize > 0.f); + } + CHECK(sawDepthZero); + CHECK(sawDeep); +} + +TEST_CASE("procgen.lsystem.meshRecipeIsDeterministic") { + Procgen proc; + + Params p1; + p1.setSeed(123); + p1.setString("style", "tree"); + p1.setInt("iterations", 4); + p1.setString("leafMode", "cards"); + + Params p2 = p1; + + auto p1Result = Procgen::newParamsHandle(); + auto p2Result = Procgen::newParamsHandle(); + REQUIRE(p1Result.ok()); + REQUIRE(p2Result.ok()); + auto p1Handle = std::move(p1Result).takeValue(); + auto p2Handle = std::move(p2Result).takeValue(); + *Procgen::resolve(p1Handle) = p1; + *Procgen::resolve(p2Handle) = p2; + auto m1Result = proc.buildMeshHandle("mesh.lsystem", p1Handle); + auto m2Result = proc.buildMeshHandle("mesh.lsystem", p2Handle); + REQUIRE(m1Result.ok()); + REQUIRE(m2Result.ok()); + auto m1Handle = std::move(m1Result).takeValue(); + auto m2Handle = std::move(m2Result).takeValue(); + auto m1 = proc.resolveMeshBuild(m1Handle); + auto m2 = proc.resolveMeshBuild(m2Handle); + REQUIRE(m1.isBound()); + REQUIRE(m2.isBound()); + CHECK(m1->getVertexCount() > 0); + CHECK_EQ(m1->getVertexCount(), m2->getVertexCount()); + CHECK_EQ(m1->getIndexCount(), m2->getIndexCount()); + CHECK_EQ(m1->getMeta("recipe", ""), std::string("mesh.lsystem")); + REQUIRE(proc.releaseMeshBuild(m2Handle).ok()); + REQUIRE(proc.releaseMeshBuild(m1Handle).ok()); + REQUIRE(Procgen::release(p2Handle).ok()); + REQUIRE(Procgen::release(p1Handle).ok()); +} + +TEST_CASE("procgen.lsystem.meshRecipeReportsErrors") { + Procgen proc; + Params params; + params.setSeed(1); + params.setString("style", "bogus"); + auto paramsResult = Procgen::newParamsHandle(); + REQUIRE(paramsResult.ok()); + auto paramsHandle = std::move(paramsResult).takeValue(); + *Procgen::resolve(paramsHandle) = params; + auto meshResult = proc.buildMeshHandle("mesh.lsystem", paramsHandle); + REQUIRE(!meshResult.ok()); + CHECK(meshResult.status().describe().find("unknown style") != std::string::npos); + REQUIRE(Procgen::release(paramsHandle).ok()); +} + +TEST_CASE("procgen.lsystem.scriptFacade") { + Procgen proc; + auto result = proc.newLSystemHandle(); + REQUIRE(result.ok()); + auto handle = std::move(result).takeValue(); + auto ls = proc.resolveLSystem(handle); + REQUIRE(ls.isBound()); + ls->setAxiom("F"); + ls->addRule('F', "F[+F]F[-F]F"); + ls->setIterations(2); + ls->setSeed(5); + + PointSet points; + ls->toPointSet(points); + CHECK(points.getCount() > 0); + REQUIRE(proc.release(handle).ok()); +} diff --git a/test/procgen_point_graph.cpp b/test/procgen_point_graph.cpp new file mode 100644 index 00000000..b5c623ba --- /dev/null +++ b/test/procgen_point_graph.cpp @@ -0,0 +1,528 @@ +#include "procgen/Biome.h" +#include "procgen/PointGraph.h" +#include "procgen/ShapeGrammar.h" + +#include +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::procgen; + +TEST_CASE("procgen.pointGraph.executesDagAndCachesResults") { + PointGraph graph; + CHECK(graph.addNode("sample", "spatial.sample")); + CHECK(graph.addNode("exclude", "spatial.filter")); + CHECK(graph.addNode("prune", "self.prune")); + CHECK(graph.connect("sample", "exclude")); + CHECK(graph.connect("exclude", "prune")); + + SpatialData domain = SpatialData::box(0.f, 0.f, 0.f, 4.f, 0.f, 4.f); + SpatialData hole = SpatialData::sphere(2.f, 0.f, 2.f, 0.75f); + CHECK(graph.setNodeSpatial("sample", &domain)); + CHECK(graph.setNodeFloat("sample", "spacing", 2.f)); + CHECK(graph.setNodeInt("sample", "seed", 42)); + CHECK(graph.setNodeSpatial("exclude", &hole)); + CHECK(graph.setNodeInt("exclude", "invert", 1)); + CHECK(graph.setNodeFloat("prune", "radius", 1.f)); + CHECK(graph.validate()); + + PointSet* first = graph.execute("prune"); + REQUIRE(bool(first)); + CHECK_EQ(first->getCount(), 8); + CHECK(graph.getMetricCount() >= 3); + PointSet* debug = graph.getNodeOutput("sample"); + REQUIRE(bool(debug)); + CHECK_EQ(debug->getCount(), 9); + + PointSet* second = graph.execute("prune"); + REQUIRE(bool(second)); + CHECK_EQ(second->getCount(), 8); + CHECK_EQ(graph.getCacheHitCount(), 1); + CHECK(graph.isMetricCacheHit(0)); + + delete second; + delete debug; + delete first; +} + +TEST_CASE("procgen.pointGraph.rejectsCyclesAndBadInputs") { + PointGraph graph; + CHECK(graph.addNode("a", "self.prune")); + CHECK(graph.addNode("b", "jitter")); + CHECK(graph.connect("a", "b")); + CHECK(graph.connect("b", "a")); + CHECK(!graph.validate()); + CHECK(graph.getError().find("cycle") != std::string::npos); + CHECK(!graph.execute("a")); + CHECK(graph.getError().find("cycle") != std::string::npos); + CHECK(!graph.addNode("unknown", "not-an-operation")); +} + +TEST_CASE("procgen.pointGraph.supportsBranchesAndNestedGraphs") { + PointSet source; + source.add(1.f, 0.f, 0.f); + + PointGraph nested; + CHECK(nested.addNode("in", "input")); + CHECK(nested.addNode("move", "transform")); + CHECK(nested.setNodePoints("in", &source)); + CHECK(nested.connect("in", "move")); + CHECK(nested.setNodeFloat("move", "x", 5.f)); + + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("nested", "subgraph")); + CHECK(graph.addNode("fallback", "transform")); + CHECK(graph.addNode("choose", "branch")); + CHECK(graph.setNodePoints("source", &source)); + CHECK(graph.connect("source", "nested")); + CHECK(graph.setNodeSubgraph("nested", &nested, "in", "move")); + CHECK(graph.connect("source", "fallback")); + CHECK(graph.setNodeFloat("fallback", "x", -5.f)); + CHECK(graph.connect("nested", "choose", 0)); + CHECK(graph.connect("fallback", "choose", 1)); + CHECK(graph.setNodeInt("choose", "condition", 1)); + + PointSet* result = graph.execute("choose"); + REQUIRE(bool(result)); + CHECK_EQ(result->getCount(), 1); + CHECK_EQ(result->getX(0), 6.f); + delete result; +} + +TEST_CASE("procgen.pointGraph.reflectsOperationsForEditors") { + CHECK(PointGraph::getOperationCount() >= 15); + CHECK_EQ(PointGraph::getOperationInputCount("merge"), 2); + CHECK_EQ(PointGraph::getOperationInputCount("spatial.sample"), 0); + CHECK_EQ(PointGraph::getOperationParamCount("transform"), 7); + CHECK_EQ(PointGraph::getOperationParamKey("spatial.sample", 0), std::string("spacing")); + CHECK_EQ(PointGraph::getOperationParamKind("spatial.sample", 1), std::string("int")); + CHECK_EQ(PointGraph::getOperationParamDefault("branch", 0), std::string("false")); + CHECK_EQ(PointGraph::getOperationInputCount("missing"), -1); +} + +TEST_CASE("procgen.pointGraph.enforcesDeclaredPinsAndValidatesExternalSubgraphInput") { + PointGraph nested; + CHECK(nested.addNode("in", "input")); + CHECK(nested.addNode("move", "transform")); + CHECK(nested.connect("in", "move")); + + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("nested", "subgraph")); + CHECK(graph.connect("source", "nested")); + CHECK(!graph.connect("source", "nested", 1)); + CHECK(!graph.connect("source", "source", 0)); + CHECK(graph.setNodeSubgraph("nested", &nested, "in", "move")); + + PointSet input; + input.add(1.f, 2.f, 3.f); + CHECK(graph.setNodePoints("source", &input)); + CHECK(graph.validate()); + PointSet* output = graph.execute("nested"); + REQUIRE(bool(output)); + CHECK_EQ(output->getCount(), 1); + delete output; +} + +TEST_CASE("procgen.pointGraph.definitionRoundTripsNestedTopologyAndParams") { + PointSet source; + source.add(1.f, 0.f, 0.f); + + PointGraph nested; + nested.addNode("in", "input"); + nested.addNode("move", "transform"); + nested.setNodePoints("in", &source); + nested.connect("in", "move"); + nested.setNodeFloat("move", "x", 5.f); + + PointGraph original; + original.addNode("source", "input"); + original.addNode("nested", "subgraph"); + original.addNode("tag", "attribute.set.string"); + original.setNodePoints("source", &source); + original.connect("source", "nested"); + original.setNodeSubgraph("nested", &nested, "in", "move"); + original.connect("nested", "tag"); + original.setNodeString("tag", "attribute", "asset"); + original.setNodeString("tag", "value", "oak \"old\""); + + const std::string definition = original.serializeDefinition(); + CHECK(definition.find("EVPCG_POINT_GRAPH 1") == 0); + PointGraph loaded; + CHECK(loaded.deserializeDefinition(definition)); + CHECK_EQ(loaded.getNodeCount(), 3); + CHECK_EQ(loaded.getInputNode("tag", 0), std::string("nested")); + CHECK(loaded.setNodePoints("source", &source)); + PointSet* result = loaded.execute("tag"); + REQUIRE(bool(result)); + CHECK_EQ(result->getX(0), 6.f); + CHECK_EQ(result->getStringAttribute(0, "asset", ""), std::string("oak \"old\"")); + delete result; + + CHECK(!loaded.deserializeDefinition("broken")); + CHECK(loaded.hasNode("source")); + CHECK(!loaded.deserializeDefinition( + "EVPCG_POINT_GRAPH 1\nNODE \"move\" \"transform\"\nS \"move\" \"x\" \"bad\"\nEND\n")); + CHECK(loaded.hasNode("source")); +} + +TEST_CASE("procgen.pointGraph.invalidatesOnlyChangedNodeDescendants") { + PointSet points; + points.add(1.f, 0.f, 0.f); + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("left", "transform")); + CHECK(graph.addNode("right", "transform")); + CHECK(graph.connect("source", "left")); + CHECK(graph.connect("source", "right")); + CHECK(graph.setNodePoints("source", &points)); + PointSet* left = graph.execute("left"); + PointSet* right = graph.execute("right"); + REQUIRE(bool(left)); + REQUIRE(bool(right)); + delete right; + delete left; + + const uint64_t revision = graph.getRevision(); + CHECK(graph.setNodeFloat("left", "x", 5.f)); + CHECK(graph.getRevision() > revision); + PointSet* sourceCache = graph.getNodeOutput("source"); + PointSet* rightCache = graph.getNodeOutput("right"); + CHECK(bool(sourceCache)); + CHECK(bool(rightCache)); + CHECK(!graph.getNodeOutput("left")); + delete rightCache; + delete sourceCache; +} + +TEST_CASE("procgen.pointGraph.definitionIsStableAcrossParameterInsertionOrder") { + PointGraph first; + PointGraph second; + CHECK(first.addNode("move", "transform")); + CHECK(second.addNode("move", "transform")); + CHECK(first.addNode("tag", "attribute.set.string")); + CHECK(second.addNode("tag", "attribute.set.string")); + CHECK(first.setNodeFloat("move", "z", 3.f)); + CHECK(first.setNodeFloat("move", "x", 1.f)); + CHECK(first.setNodeString("tag", "value", "oak")); + CHECK(first.setNodeString("tag", "attribute", "asset")); + CHECK(second.setNodeString("tag", "attribute", "asset")); + CHECK(second.setNodeString("tag", "value", "oak")); + CHECK(second.setNodeFloat("move", "x", 1.f)); + CHECK(second.setNodeFloat("move", "z", 3.f)); + CHECK_EQ(first.serializeDefinition(), second.serializeDefinition()); + CHECK(!first.setNodeString("move", "typo", "rejected")); + CHECK(!first.setNodeInt("move", "x", 1)); + CHECK(!first.setNodeFloat("tag", "value", 1.f)); +} + +TEST_CASE("procgen.pointGraph.exposesTypedInstanceParameters") { + PointSet points; + points.add(1.f, 0.f, 0.f); + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("move", "transform")); + CHECK(graph.connect("source", "move")); + CHECK(graph.setNodePoints("source", &points)); + CHECK(graph.setNodeFloat("move", "x", 2.f)); + CHECK(graph.exposeParameter("offset", "move", "x")); + CHECK_EQ(graph.getParameterCount(), 1); + CHECK_EQ(graph.getParameterName(0), std::string("offset")); + CHECK_EQ(graph.getParameterKind("offset"), std::string("float")); + CHECK_EQ(graph.getParameterFloat("offset", -1.f), 2.f); + CHECK(!graph.exposeParameter("alias", "move", "x")); + CHECK(!graph.exposeParameter("bad", "move", "missing")); + CHECK(!graph.setParameterInt("offset", 7)); + + PointSet* result = graph.execute("move"); + REQUIRE(bool(result)); + CHECK_EQ(result->getX(0), 3.f); + delete result; + + CHECK(graph.setParameterFloat("offset", 5.f)); + CHECK(graph.hasParameterOverride("offset")); + CHECK_EQ(graph.getParameterFloat("offset", -1.f), 5.f); + result = graph.execute("move"); + REQUIRE(bool(result)); + CHECK_EQ(result->getX(0), 6.f); + delete result; + + const std::string definition = graph.serializeDefinition(); + PointGraph loaded; + CHECK(loaded.deserializeDefinition(definition)); + CHECK_EQ(loaded.getParameterKind("offset"), std::string("float")); + CHECK(!loaded.hasParameterOverride("offset")); + CHECK_EQ(loaded.getParameterFloat("offset", -1.f), 2.f); + CHECK(loaded.setNodePoints("source", &points)); + result = loaded.execute("move"); + REQUIRE(bool(result)); + CHECK_EQ(result->getX(0), 3.f); + delete result; + + CHECK(graph.clearParameterOverride("offset")); + CHECK(!graph.hasParameterOverride("offset")); + CHECK_EQ(graph.getParameterFloat("offset", -1.f), 2.f); + CHECK(graph.removeNode("move")); + CHECK_EQ(graph.getParameterCount(), 0); +} + +TEST_CASE("procgen.pointGraph.serializesExposedParametersDeterministically") { + PointGraph first; + PointGraph second; + for (PointGraph* graph : {&first, &second}) { + CHECK(graph->addNode("move", "transform")); + CHECK(graph->addNode("tag", "attribute.set.string")); + } + CHECK(first.exposeParameter("zOffset", "move", "z")); + CHECK(first.exposeParameter("asset", "tag", "value")); + CHECK(second.exposeParameter("asset", "tag", "value")); + CHECK(second.exposeParameter("zOffset", "move", "z")); + CHECK_EQ(first.serializeDefinition(), second.serializeDefinition()); +} + +TEST_CASE("procgen.pointGraph.cooperativelyBudgetsAndCancelsExecution") { + PointSet points; + points.add(0.f, 0.f, 0.f); + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("first", "transform")); + CHECK(graph.addNode("second", "transform")); + CHECK(graph.connect("source", "first")); + CHECK(graph.connect("first", "second")); + CHECK(graph.setNodePoints("source", &points)); + graph.setExecutionNodeBudget(2); + CHECK_EQ(graph.getExecutionNodeBudget(), 2); + CHECK(!graph.execute("second")); + CHECK(graph.wasCancelled()); + CHECK(graph.getError().find("budget exceeded") != std::string::npos); + PointSet* partial = graph.getNodeOutput("first"); + REQUIRE(bool(partial)); + delete partial; + + graph.setExecutionNodeBudget(3); + PointSet* completed = graph.execute("second"); + REQUIRE(bool(completed)); + CHECK(!graph.wasCancelled()); + CHECK(graph.getCacheHitCount() > 0); + delete completed; + + graph.requestCancel(); + CHECK(!graph.execute("second")); + CHECK(graph.wasCancelled()); + CHECK(graph.getError().find("cancelled") != std::string::npos); + graph.resetCancellation(); + CHECK(!graph.wasCancelled()); + completed = graph.execute("second"); + REQUIRE(bool(completed)); + delete completed; +} + +TEST_CASE("procgen.pointGraph.copiesSourcePointsAcrossTargetsWithBoundedOutput") { + PointSet source; + const int sourcePoint = source.add(1.f, 2.f, 0.f); + source.setPointSeed(sourcePoint, 11); + source.setFloatAttribute(sourcePoint, "source", 2.f); + source.setStringAttribute(sourcePoint, "shared", "source"); + PointSet targets; + const int firstTarget = targets.add(10.f, 20.f, 30.f); + targets.setYaw(firstTarget, 90.f); + targets.setScale(firstTarget, 2.f, 3.f, 4.f); + targets.setDensity(firstTarget, 0.5f); + targets.setPointSeed(firstTarget, 101); + targets.setFloatAttribute(firstTarget, "target", 3.f); + targets.setStringAttribute(firstTarget, "shared", "target"); + targets.add(-10.f, 0.f, 0.f); + + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("targets", "input")); + CHECK(graph.addNode("copies", "copy.points")); + CHECK(graph.setNodePoints("source", &source)); + CHECK(graph.setNodePoints("targets", &targets)); + CHECK(graph.connect("source", "copies", 0)); + CHECK(graph.connect("targets", "copies", 1)); + CHECK(graph.setNodeInt("copies", "maxPoints", 2)); + PointSet* result = graph.execute("copies"); + REQUIRE(bool(result)); + CHECK_EQ(result->getCount(), 2); + CHECK(std::abs(result->getX(0) - 10.f) < 0.0001f); + CHECK_EQ(result->getY(0), 26.f); + CHECK(std::abs(result->getZ(0) - 32.f) < 0.0001f); + CHECK_EQ(result->getYaw(0), 90.f); + CHECK_EQ(result->getScaleX(0), 2.f); + CHECK_EQ(result->getDensity(0), 0.5f); + CHECK_EQ(result->getFloatAttribute(0, "target", -1.f), 3.f); + CHECK_EQ(result->getFloatAttribute(0, "source", -1.f), 2.f); + CHECK_EQ(result->getStringAttribute(0, "shared", ""), std::string("source")); + delete result; + + CHECK(graph.setNodeInt("copies", "maxPoints", 1)); + CHECK(!graph.execute("copies")); + CHECK(graph.getError().find("maxPoints") != std::string::npos); +} + +TEST_CASE("procgen.pointGraph.validatesEveryRequiredInput") { + PointSet points; + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("copies", "copy.points")); + CHECK(graph.setNodePoints("source", &points)); + CHECK(graph.connect("source", "copies", 0)); + CHECK(!graph.validate()); + CHECK(graph.getError().find("input 1") != std::string::npos); +} + +TEST_CASE("procgen.pointGraph.remapsDensityAndComputesFloatMetadata") { + PointSet points; + const int first = points.add(0.f, 0.f, 0.f); + const int second = points.add(1.f, 0.f, 0.f); + points.setDensity(first, -1.f); + points.setDensity(second, 0.5f); + points.setFloatAttribute(first, "age", 3.f); + + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("density", "density.remap")); + CHECK(graph.addNode("math", "attribute.math.float")); + CHECK(graph.setNodePoints("source", &points)); + CHECK(graph.connect("source", "density")); + CHECK(graph.connect("density", "math")); + CHECK(graph.setNodeFloat("density", "inputMin", 0.f)); + CHECK(graph.setNodeFloat("density", "inputMax", 1.f)); + CHECK(graph.setNodeFloat("density", "outputMin", 10.f)); + CHECK(graph.setNodeFloat("density", "outputMax", 20.f)); + CHECK(graph.setNodeString("math", "attribute", "age")); + CHECK(graph.setNodeString("math", "outputAttribute", "score")); + CHECK(graph.setNodeString("math", "operation", "multiply")); + CHECK(graph.setNodeFloat("math", "operand", 2.f)); + CHECK(graph.setNodeFloat("math", "defaultValue", 4.f)); + PointSet* result = graph.execute("math"); + REQUIRE(bool(result)); + CHECK_EQ(result->getDensity(0), 10.f); + CHECK_EQ(result->getDensity(1), 15.f); + CHECK_EQ(result->getFloatAttribute(0, "score", -1.f), 6.f); + CHECK_EQ(result->getFloatAttribute(1, "score", -1.f), 8.f); + delete result; + + CHECK(graph.setNodeString("math", "operation", "divide")); + CHECK(graph.setNodeFloat("math", "operand", 0.f)); + CHECK(!graph.execute("math")); + CHECK(graph.getError().find("divide by zero") != std::string::npos); + CHECK(graph.setNodeFloat("density", "inputMax", 0.f)); + CHECK(!graph.execute("density")); + CHECK(graph.getError().find("non-zero input range") != std::string::npos); +} + +TEST_CASE("procgen.pointGraph.cachesSpatialMetricsForDebugVisualization") { + PointSet points; + const int first = points.add(-2.f, 3.f, 4.f); + const int second = points.add(6.f, -1.f, 8.f); + points.setDensity(first, 0.25f); + points.setDensity(second, 0.75f); + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.setNodePoints("source", &points)); + PointSet* output = graph.execute("source"); + REQUIRE(bool(output)); + delete output; + CHECK_EQ(graph.getMetricCount(), 1); + CHECK_EQ(graph.getMetricMinX(0), -2.f); + CHECK_EQ(graph.getMetricMinY(0), -1.f); + CHECK_EQ(graph.getMetricMinZ(0), 4.f); + CHECK_EQ(graph.getMetricMaxX(0), 6.f); + CHECK_EQ(graph.getMetricMaxY(0), 3.f); + CHECK_EQ(graph.getMetricMaxZ(0), 8.f); + CHECK_EQ(graph.getMetricAverageDensity(0), 0.5f); + + output = graph.execute("source"); + REQUIRE(bool(output)); + delete output; + CHECK(graph.isMetricCacheHit(0)); + CHECK_EQ(graph.getMetricMinX(0), -2.f); + CHECK_EQ(graph.getMetricAverageDensity(0), 0.5f); +} + +TEST_CASE("procgen.pointGraph.instantiatesIsolatedRuntimeState") { + PointSet points; + points.add(1.f, 0.f, 0.f); + PointGraph asset; + CHECK(asset.addNode("source", "input")); + CHECK(asset.addNode("move", "transform")); + CHECK(asset.connect("source", "move")); + CHECK(asset.setNodePoints("source", &points)); + CHECK(asset.setNodeFloat("move", "x", 2.f)); + CHECK(asset.exposeParameter("offset", "move", "x")); + CHECK(asset.setParameterFloat("offset", 9.f)); + + PointGraph* instance = asset.instantiate(); + REQUIRE(bool(instance)); + CHECK(!instance->hasParameterOverride("offset")); + CHECK_EQ(instance->getParameterFloat("offset", -1.f), 2.f); + CHECK(!instance->execute("move")); + CHECK(instance->getError().find("no points") != std::string::npos); + CHECK(instance->setNodePoints("source", &points)); + CHECK(instance->setParameterFloat("offset", 4.f)); + PointSet* output = instance->execute("move"); + REQUIRE(bool(output)); + CHECK_EQ(output->getX(0), 5.f); + delete output; + CHECK_EQ(asset.getParameterFloat("offset", -1.f), 9.f); + delete instance; +} + +TEST_CASE("procgen.pointGraph.generatesBiomeThroughReflectedNode") { + SpatialData domain = SpatialData::box(0.f, 0.f, 0.f, 4.f, 0.f, 4.f); + BiomeRules rules; + CHECK(rules.addLayer("forest", &domain, 1, 1.f)); + CHECK(rules.addAsset("forest", "oak", 1.f, 1.f, 1.f, false)); + + PointGraph graph; + CHECK(graph.addNode("biome", "biome.generate")); + CHECK(graph.setNodeSpatial("biome", &domain)); + CHECK(graph.setNodeBiomeRules("biome", &rules)); + CHECK(graph.setNodeFloat("biome", "spacing", 2.f)); + CHECK(graph.setNodeInt("biome", "seed", 42)); + CHECK(graph.validate()); + PointSet* output = graph.execute("biome"); + REQUIRE(bool(output)); + CHECK_EQ(output->getCount(), 9); + CHECK_EQ(output->getStringAttribute(0, "biome", ""), std::string("forest")); + CHECK_EQ(output->getStringAttribute(0, "asset", ""), std::string("oak")); + delete output; +} + +TEST_CASE("procgen.pointGraph.expandsShapeGrammarAndRebindsExternalAssets") { + PointSet controlPoints; + controlPoints.add(0.f, 0.f, 0.f); + controlPoints.add(6.f, 0.f, 0.f); + ShapeGrammar grammar; + CHECK(grammar.addModule("A", "wall", 2.f, 1.f)); + + PointGraph asset; + CHECK(asset.addNode("path", "input")); + CHECK(asset.addNode("facade", "grammar.generate")); + CHECK(asset.connect("path", "facade")); + CHECK(asset.setNodePoints("path", &controlPoints)); + CHECK(asset.setNodeShapeGrammar("facade", &grammar)); + CHECK(asset.setNodeString("facade", "grammar", "A+")); + CHECK(asset.validate()); + PointSet* output = asset.execute("facade"); + REQUIRE(bool(output)); + CHECK_EQ(output->getCount(), 3); + CHECK_EQ(output->getStringAttribute(0, "asset", ""), std::string("wall")); + delete output; + + PointGraph* instance = asset.instantiate(); + REQUIRE(bool(instance)); + CHECK(instance->setNodePoints("path", &controlPoints)); + CHECK(!instance->validate()); + CHECK(instance->getError().find("shape grammar") != std::string::npos); + CHECK(instance->setNodeShapeGrammar("facade", &grammar)); + CHECK(instance->validate()); + output = instance->execute("facade"); + REQUIRE(bool(output)); + CHECK_EQ(output->getCount(), 3); + delete output; + delete instance; +} diff --git a/test/procgen_point_graph_stress.cpp b/test/procgen_point_graph_stress.cpp new file mode 100644 index 00000000..501f0059 --- /dev/null +++ b/test/procgen_point_graph_stress.cpp @@ -0,0 +1,211 @@ +#include "procgen/Biome.h" +#include "procgen/PointGraph.h" +#include "procgen/ShapeGrammar.h" + +#include +#include +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::procgen; + +namespace { + +bool pointSetsEqual(const PointSet& first, const PointSet& second) { + if (first.getCount() != second.getCount()) return false; + for (int index = 0; index < first.getCount(); ++index) { + const ProcgenPoint& a = first.points()[size_t(index)]; + const ProcgenPoint& b = second.points()[size_t(index)]; + if (a.x != b.x || a.y != b.y || a.z != b.z || a.normalX != b.normalX || + a.normalY != b.normalY || a.normalZ != b.normalZ || a.yaw != b.yaw || + a.scaleX != b.scaleX || a.scaleY != b.scaleY || a.scaleZ != b.scaleZ || + a.density != b.density || a.seed != b.seed || + a.floatAttributes != b.floatAttributes || a.stringAttributes != b.stringAttributes) + return false; + } + return true; +} + +} // namespace + +TEST_CASE("procgen.pointGraphStress.resumesDeepGraphAcrossBudgetWindows") { + constexpr int nodeCount = 64; + PointSet input; + input.add(0.f, 0.f, 0.f); + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.setNodePoints("source", &input)); + std::string previous = "source"; + for (int index = 0; index < nodeCount; ++index) { + const std::string id = "move-" + std::to_string(index); + CHECK(graph.addNode(id, "transform")); + CHECK(graph.connect(previous, id)); + CHECK(graph.setNodeFloat(id, "x", 0.25f)); + previous = id; + } + CHECK(graph.validate()); + graph.setExecutionNodeBudget(17); + + std::unique_ptr output; + int budgetWindows = 0; + while (!output && budgetWindows < 20) { + output.reset(graph.execute(previous)); + ++budgetWindows; + if (!output) CHECK(graph.wasCancelled()); + } + REQUIRE(bool(output)); + CHECK_EQ(output->getX(0), 16.f); + CHECK_EQ(budgetWindows, 4); + CHECK(graph.getCacheHitCount() > 0); +} + +TEST_CASE("procgen.pointGraphStress.computePolicyPreservesDeterministicCpuPath") { + PointSet input; + input.add(2.f, 3.f, 4.f); + PointGraph graph; + CHECK(graph.addNode("source", "input")); + CHECK(graph.addNode("move", "transform")); + CHECK(graph.connect("source", "move")); + CHECK(graph.setNodePoints("source", &input)); + CHECK(graph.setNodeFloat("move", "x", 5.f)); + CHECK(!graph.setComputePolicy("invalid")); + CHECK(graph.setComputePolicy("cpu")); + graph.setComputeMinimumPoints(0); + + std::unique_ptr output(graph.execute("move")); + REQUIRE(bool(output)); + CHECK_EQ(output->getX(0), 7.f); + CHECK_EQ(graph.getComputePolicy(), "cpu"); + CHECK_EQ(graph.getComputeMinimumPoints(), 0); + CHECK_EQ(graph.getMetricBackend(graph.getMetricCount() - 1), "cpu"); + CHECK(graph.getComputeFallbackReason().empty()); + CHECK_EQ(graph.getComputeUploadCount(), uint64_t(0)); + CHECK_EQ(graph.getComputeDispatchCount(), uint64_t(0)); + CHECK_EQ(graph.getComputeReadbackCount(), uint64_t(0)); + CHECK_EQ(graph.getComputeBufferReuseCount(), uint64_t(0)); + CHECK_EQ(graph.getComputePeakBufferBytes(), uint64_t(0)); + CHECK_EQ(graph.getLastFusedTransformCount(), 0); + CHECK(graph.debugReport().find("backend=cpu") != std::string::npos); +} + +TEST_CASE("procgen.pointGraphStress.isBitStableAcrossIndependentBiomeInstances") { + SpatialData domain = SpatialData::box(-32.f, 0.f, -32.f, 32.f, 0.f, 32.f); + SpatialData center = SpatialData::sphere(0.f, 0.f, 0.f, 12.f); + BiomeRules rules; + CHECK(rules.addLayer("forest", &domain, 1, 0.85f)); + CHECK(rules.addLayer("clearing", ¢er, 5, 0.35f)); + CHECK(rules.addAsset("forest", "oak", 3.f, 0.8f, 1.3f, true)); + CHECK(rules.addAsset("forest", "pine", 1.f, 0.9f, 1.4f, true)); + CHECK(rules.addAsset("clearing", "rock", 1.f, 0.7f, 1.1f, true)); + + PointGraph asset; + CHECK(asset.addNode("biome", "biome.generate")); + CHECK(asset.setNodeFloat("biome", "spacing", 1.f)); + CHECK(asset.setNodeFloat("biome", "jitter", 0.75f)); + CHECK(asset.setNodeInt("biome", "seed", 0x5a17)); + std::unique_ptr first(asset.instantiate()); + std::unique_ptr second(asset.instantiate()); + REQUIRE(bool(first)); + REQUIRE(bool(second)); + CHECK(first->setNodeSpatial("biome", &domain)); + CHECK(first->setNodeBiomeRules("biome", &rules)); + CHECK(second->setNodeSpatial("biome", &domain)); + CHECK(second->setNodeBiomeRules("biome", &rules)); + + std::unique_ptr firstOutput(first->execute("biome")); + std::unique_ptr secondOutput(second->execute("biome")); + REQUIRE(bool(firstOutput)); + REQUIRE(bool(secondOutput)); + CHECK(firstOutput->getCount() > 3000); + CHECK(firstOutput->getCount() < 4225); + CHECK(pointSetsEqual(*firstOutput, *secondOutput)); +} + +TEST_CASE("procgen.pointGraphStress.recoversAfterExternalRuleAndDataErrors") { + PointSet path; + path.add(0.f, 0.f, 0.f); + path.add(10.f, 0.f, 0.f); + ShapeGrammar grammar; + CHECK(grammar.addModule("A", "wall", 2.f)); + PointGraph graph; + CHECK(graph.addNode("path", "input")); + CHECK(graph.addNode("facade", "grammar.generate")); + CHECK(graph.connect("path", "facade")); + CHECK(graph.setNodeString("facade", "grammar", "Missing+")); + CHECK(!graph.execute("facade")); + CHECK(graph.getError().find("no points") != std::string::npos); + + CHECK(graph.setNodePoints("path", &path)); + CHECK(graph.setNodeShapeGrammar("facade", &grammar)); + CHECK(!graph.execute("facade")); + CHECK(graph.getError().find("unknown module") != std::string::npos); + CHECK(graph.setNodeString("facade", "grammar", "A+")); + std::unique_ptr recovered(graph.execute("facade")); + REQUIRE(bool(recovered)); + CHECK_EQ(recovered->getCount(), 5); + CHECK_EQ(recovered->getStringAttribute(0, "asset", ""), std::string("wall")); +} + +TEST_CASE("procgen.pointGraphStress.rejectsOversizedOutputsBeforeSpatialAllocation") { + SpatialData domain = SpatialData::box(0.f, 0.f, 0.f, 100.f, 0.f, 100.f); + PointGraph graph; + CHECK(graph.addNode("sample", "spatial.sample")); + CHECK(graph.setNodeSpatial("sample", &domain)); + CHECK(graph.setNodeFloat("sample", "spacing", 1.f)); + graph.setMaxNodeOutputPoints(1000); + CHECK_EQ(graph.getMaxNodeOutputPoints(), 1000); + CHECK(!graph.execute("sample")); + CHECK(graph.getError().find("point budget") != std::string::npos); + CHECK(!graph.getNodeOutput("sample")); + + graph.setMaxNodeOutputPoints(11000); + std::unique_ptr output(graph.execute("sample")); + REQUIRE(bool(output)); + CHECK_EQ(output->getCount(), 10201); + + graph.setMaxNodeOutputPoints(1000); + CHECK(!graph.execute("sample")); + CHECK(graph.getError().find("point budget") != std::string::npos); + + Heightmap heightmap(11, 11); + heightmap.setHeight(10, 10, 100.f); + SpatialData terrain = SpatialData::heightfield(heightmap, 0.f, 0.f, 1.f, 1.f); + PointGraph terrainGraph; + CHECK(terrainGraph.addNode("sample", "spatial.sample")); + CHECK(terrainGraph.setNodeSpatial("sample", &terrain)); + terrainGraph.setMaxNodeOutputPoints(121); + output.reset(terrainGraph.execute("sample")); + REQUIRE(bool(output)); + CHECK_EQ(output->getCount(), 121); +} + +TEST_CASE("procgen.pointGraphStress.preflightsInputMergeAndCopyAllocations") { + PointSet first; + PointSet second; + for (int index = 0; index < 60; ++index) { + first.add(float(index), 0.f, 0.f); + second.add(0.f, 0.f, float(index)); + } + PointGraph graph; + CHECK(graph.addNode("first", "input")); + CHECK(graph.addNode("second", "input")); + CHECK(graph.addNode("merge", "merge")); + CHECK(graph.addNode("copy", "copy.points")); + graph.setMaxNodeOutputPoints(100); + CHECK(graph.setNodePoints("first", &first)); + CHECK(graph.setNodePoints("second", &second)); + CHECK(graph.connect("first", "merge", 0)); + CHECK(graph.connect("second", "merge", 1)); + CHECK(graph.connect("first", "copy", 0)); + CHECK(graph.connect("second", "copy", 1)); + CHECK(graph.setNodeInt("copy", "maxPoints", 10000)); + CHECK(!graph.execute("merge")); + CHECK(graph.getError().find("point budget") != std::string::npos); + CHECK(!graph.execute("copy")); + CHECK(graph.getError().find("point budget") != std::string::npos); + + PointSet oversized; + for (int index = 0; index < 101; ++index) oversized.add(float(index), 0.f, 0.f); + CHECK(!graph.setNodePoints("first", &oversized)); + CHECK(graph.getError().find("input exceeds") != std::string::npos); +} diff --git a/test/procgen_poisson.cpp b/test/procgen_poisson.cpp new file mode 100644 index 00000000..62f68e7e --- /dev/null +++ b/test/procgen_poisson.cpp @@ -0,0 +1,136 @@ +#include "procgen/PointSet.h" +#include "procgen/Procgen.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include +#include + +using namespace eve::procgen; + +namespace { + +/** @brief Owns a Procgen point-set handle for one test's lifetime. */ +class PointSetLease { +public: + PointSetLease() = default; + PointSetLease(Procgen& owner, ProcgenPointSetHandleRef handle) + : owner_(&owner), handle_(handle) {} + + PointSetLease(const PointSetLease&) = delete; + PointSetLease& operator=(const PointSetLease&) = delete; + PointSetLease(PointSetLease&& other) noexcept + : owner_(other.owner_), handle_(other.handle_) { + other.owner_ = nullptr; + other.handle_ = {}; + } + PointSetLease& operator=(PointSetLease&& other) noexcept { + if (this == &other) return *this; + reset(); + owner_ = other.owner_; + handle_ = other.handle_; + other.owner_ = nullptr; + other.handle_ = {}; + return *this; + } + ~PointSetLease() { reset(); } + + /** @brief Releases the owned handle and explicitly observes the cleanup Result. */ + void reset() noexcept { + if (!owner_ || !handle_.isValid()) return; + auto result = owner_->releasePointSet(handle_); + result.ignore("test point-set cleanup"); + owner_ = nullptr; + handle_ = {}; + } + + /** @brief Resolves the handle for the duration of the current test operation. */ + [[nodiscard]] eve::script::Borrowed view() const noexcept { + return owner_ ? owner_->resolvePointSet(handle_) + : eve::script::Borrowed(); + } + +private: + Procgen* owner_ = nullptr; + ProcgenPointSetHandleRef handle_{}; +}; + +/** @brief Converts a checked point-set allocation Result into a test lease. */ +PointSetLease requirePointSet(Procgen& proc, + eve::Result&& result) { + const bool ok = result.ok(); + if (!ok) { + const eve::Diagnostic* diagnostic = result.error(); + REQUIRE(diagnostic != nullptr); + } + REQUIRE(ok); + return PointSetLease(proc, std::move(result).takeValue()); +} + +} // namespace + +TEST_CASE("procgen.poisson.deterministicAndSeeded") { + Procgen proc; + + auto a = requirePointSet(proc, proc.poissonDiskHandle(40, 30, 2.5f, 99, 300)); + auto b = requirePointSet(proc, proc.poissonDiskHandle(40, 30, 2.5f, 99, 300)); + auto c = requirePointSet(proc, proc.poissonDiskHandle(40, 30, 2.5f, 100, 300)); + auto aView = a.view(); + auto bView = b.view(); + auto cView = c.view(); + REQUIRE(aView.isBound()); + REQUIRE(bView.isBound()); + REQUIRE(cView.isBound()); + CHECK_EQ(aView->getCount(), bView->getCount()); + CHECK(aView->getCount() > 0); + for (int i = 0; i < aView->getCount(); ++i) { + CHECK_EQ(aView->getX(i), bView->getX(i)); + CHECK_EQ(aView->getZ(i), bView->getZ(i)); + } +} + +TEST_CASE("procgen.poisson.respectsMinSpacing") { + Procgen proc; + auto points = requirePointSet(proc, proc.poissonDiskHandle(50, 50, 2.f, 7, 400)); + auto view = points.view(); + REQUIRE(view.isBound()); + CHECK(view->getCount() > 1); + + const float minDist = 1.9f; // allow a little float slack + for (int i = 0; i < view->getCount(); ++i) { + for (int j = i + 1; j < view->getCount(); ++j) { + const float dx = view->getX(i) - view->getX(j); + const float dz = view->getZ(i) - view->getZ(j); + const float d = std::sqrt(dx * dx + dz * dz); + CHECK(d >= minDist); + } + } +} + +TEST_CASE("procgen.poisson.maxPointsCapsOutput") { + Procgen proc; + auto capped = requirePointSet(proc, proc.poissonDiskHandle(100, 100, 0.5f, 3, 50)); + auto cappedView = capped.view(); + REQUIRE(cappedView.isBound()); + CHECK(cappedView->getCount() <= 50); + + auto tiny = requirePointSet(proc, proc.poissonDiskHandle(0, 0, 1.f, 3, 100)); + auto tinyView = tiny.view(); + REQUIRE(tinyView.isBound()); + CHECK_EQ(tinyView->getCount(), 0); +} + +TEST_CASE("procgen.poisson.allPointsInsideArea") { + Procgen proc; + auto points = requirePointSet(proc, proc.poissonDiskHandle(20, 20, 1.5f, 11, 200)); + auto view = points.view(); + REQUIRE(view.isBound()); + for (int i = 0; i < view->getCount(); ++i) { + CHECK(view->getX(i) >= 0.f); + CHECK(view->getX(i) <= 20.f); + CHECK(view->getZ(i) >= 0.f); + CHECK(view->getZ(i) <= 20.f); + CHECK_EQ(view->getY(i), 0.f); + } +} diff --git a/test/procgen_runtime_generation.cpp b/test/procgen_runtime_generation.cpp new file mode 100644 index 00000000..643085b7 --- /dev/null +++ b/test/procgen_runtime_generation.cpp @@ -0,0 +1,487 @@ +#include "procgen/RuntimeGeneration.h" +#include "procgen/Procgen.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include +#include + +using namespace eve::procgen; + +namespace { + +/** @brief Owns a module-backed runtime-generation handle for one test. */ +class RuntimeLease { +public: + RuntimeLease() = default; + RuntimeLease(Procgen& owner, ProcgenRuntimeGenerationHandleRef handle) + : owner_(&owner), handle_(handle) {} + + RuntimeLease(const RuntimeLease&) = delete; + RuntimeLease& operator=(const RuntimeLease&) = delete; + RuntimeLease(RuntimeLease&& other) noexcept + : owner_(other.owner_), handle_(other.handle_) { + other.owner_ = nullptr; + other.handle_ = {}; + } + RuntimeLease& operator=(RuntimeLease&& other) noexcept { + if (this == &other) return *this; + reset(); + owner_ = other.owner_; + handle_ = other.handle_; + other.owner_ = nullptr; + other.handle_ = {}; + return *this; + } + ~RuntimeLease() { reset(); } + + /** @brief Releases the scheduler handle and explicitly observes the Result. */ + void reset() noexcept { + if (!owner_ || !handle_.isValid()) return; + auto result = owner_->release(handle_); + result.ignore("test runtime-generation cleanup"); + owner_ = nullptr; + handle_ = {}; + } + + /** @brief Provides a short-lived borrowed view of the scheduler. */ + [[nodiscard]] eve::script::Borrowed view() const noexcept { + return owner_ ? owner_->resolveRuntimeGeneration(handle_) + : eve::script::Borrowed(); + } + + /** @brief Preserves the concise `runtime->operation()` test syntax. */ + [[nodiscard]] eve::script::Borrowed operator->() const noexcept { + return view(); + } + +private: + Procgen* owner_ = nullptr; + ProcgenRuntimeGenerationHandleRef handle_{}; +}; + +/** @brief Converts a checked scheduler allocation Result into a test lease. */ +RuntimeLease requireRuntime(Procgen& proc, uint32_t worldSeed) { + auto result = proc.newRuntimeGenerationHandle(worldSeed); + const bool ok = result.ok(); + if (!ok) { + const eve::Diagnostic* diagnostic = result.error(); + REQUIRE(diagnostic != nullptr); + } + REQUIRE(ok); + return RuntimeLease(proc, std::move(result).takeValue()); +} + +/** @brief Gives an owning lifetime to a request allocated by the scheduler API. */ +using RequestLease = std::unique_ptr; + +[[nodiscard]] RequestLease ownRequest(ProcgenCellRequest* request) noexcept { + return RequestLease(request); +} + +} // namespace + +TEST_CASE("procgen.runtimeGeneration.partitionsAndPublishesCells") { + Procgen proc; + auto runtime = requireRuntime(proc, 42); + const int nearLevel = runtime->addLevel(10.f, 8.f, 1.5f); + const int farLevel = runtime->addLevel(40.f, 30.f, 2.f); + CHECK_EQ(nearLevel, 0); + CHECK_EQ(farLevel, 1); + CHECK_EQ(runtime->getLevelCount(), 2); + CHECK_EQ(runtime->getLevelCleanupRadius(0), 12.f); + runtime->setMaxGenerating(1); + runtime->setFrameTimeBudget(2.f); + CHECK_EQ(runtime->getFrameTimeBudget(), 2.f); + runtime->beginFrame(); + runtime->updateSource(5.f, 5.f, 1.f, 0.f); + CHECK(runtime->getPendingGenerateCount() > 0); + + auto request = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(request)); + CHECK_EQ(runtime->getGeneratingCount(), 1); + CHECK(!runtime->nextGenerate()); + CHECK_NE(request->getSeed(), uint32_t(0)); + CHECK(request->getMaxX() > request->getMinX()); + + const int level = request->getLevel(); + const int x = request->getX(); + const int z = request->getZ(); + PointSet output; + output.add(request->getMinX(), 0.f, request->getMinZ()); + CHECK(runtime->completeGeneration(request.get(), &output)); + CHECK(runtime->hasCell(level, x, z)); + CHECK_EQ(runtime->getCellRevision(level, x, z), uint64_t(1)); + auto stored = std::unique_ptr(runtime->getCellOutput(level, x, z)); + REQUIRE(bool(stored)); + CHECK_EQ(stored->getCount(), 1); +} + +TEST_CASE("procgen.runtimeGeneration.cleansBeyondHysteresisRadius") { + Procgen proc; + auto runtime = requireRuntime(proc, 7); + runtime->addLevel(10.f, 6.f, 2.f); + runtime->updateSource(5.f, 5.f, 0.f, 1.f); + auto generated = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(generated)); + const int cellX = generated->getX(); + const int cellZ = generated->getZ(); + PointSet points; + points.add(5.f, 0.f, 5.f); + CHECK(runtime->completeGeneration(generated.get(), &points)); + + runtime->updateSource(100.f, 100.f, 0.f, 1.f); + CHECK(runtime->getPendingCleanupCount() > 0); + bool cleanedTarget = false; + while (auto cleanup = ownRequest(runtime->nextCleanup())) { + if (cleanup->getX() == cellX && cleanup->getZ() == cellZ) cleanedTarget = true; + CHECK(runtime->completeCleanup(cleanup.get())); + } + CHECK(cleanedTarget); + CHECK(!runtime->hasCell(0, cellX, cellZ)); +} + +TEST_CASE("procgen.runtimeGeneration.failedWorkCanRetryWithStableSeed") { + Procgen proc; + auto runtime = requireRuntime(proc, 99); + runtime->addLevel(8.f, 5.f, 1.25f); + runtime->updateSource(4.f, 4.f, 1.f, 0.f); + auto first = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(first)); + const uint32_t seed = first->getSeed(); + CHECK(runtime->failGeneration(first.get())); + + auto retry = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(retry)); + CHECK_EQ(retry->getSeed(), seed); + CHECK_EQ(retry->getLevel(), 0); +} + +TEST_CASE("procgen.runtimeGeneration.unionsNamedSourcesAndCleansAfterLastSource") { + Procgen proc; + auto runtime = requireRuntime(proc, 123); + runtime->addLevel(10.f, 16.f, 1.5f); + runtime->setMaxGenerating(100); + CHECK(runtime->setGenerationSource("player", 5.f, 5.f, 1.f, 0.f, 1.f)); + CHECK(runtime->setGenerationSource("quest", 105.f, 5.f, 0.f, 0.f, 1.f)); + CHECK_EQ(runtime->getGenerationSourceCount(), 2); + CHECK_EQ(runtime->getGenerationSourceId(0), std::string("player")); + CHECK_EQ(runtime->getGenerationSourceId(1), std::string("quest")); + + bool generatedNearPlayer = false; + bool generatedNearQuest = false; + PointSet empty; + while (auto request = ownRequest(runtime->nextGenerate())) { + if (request->getX() >= -1 && request->getX() <= 1) generatedNearPlayer = true; + if (request->getX() >= 9 && request->getX() <= 11) generatedNearQuest = true; + CHECK(runtime->completeGeneration(request.get(), &empty)); + } + CHECK(generatedNearPlayer); + CHECK(generatedNearQuest); + + CHECK(runtime->removeGenerationSource("player")); + bool cleanupNearPlayer = false; + bool cleanupNearQuest = false; + while (auto request = ownRequest(runtime->nextCleanup())) { + if (request->getX() >= -1 && request->getX() <= 1) cleanupNearPlayer = true; + if (request->getX() >= 9 && request->getX() <= 11) cleanupNearQuest = true; + CHECK(runtime->completeCleanup(request.get())); + } + CHECK(cleanupNearPlayer); + CHECK(!cleanupNearQuest); +} + +TEST_CASE("procgen.runtimeGeneration.frustumCullsFarBehindButKeepsNearCells") { + Procgen proc; + auto runtime = requireRuntime(proc, 9); + runtime->addLevel(10.f, 35.f, 1.25f); + runtime->setMaxGenerating(100); + runtime->setFrustumCulling(true, 45.f, 11.f); + CHECK(runtime->isFrustumCullingEnabled()); + CHECK_EQ(runtime->getFrustumHalfAngle(), 45.f); + CHECK_EQ(runtime->getFrustumBehindRadius(), 11.f); + CHECK(runtime->setGenerationSource("camera", 5.f, 5.f, 1.f, 0.f, 1.f)); + + bool foundFarBehind = false; + bool foundNearBehind = false; + bool foundForward = false; + PointSet empty; + while (auto request = ownRequest(runtime->nextGenerate())) { + const float centerX = (request->getMinX() + request->getMaxX()) * 0.5f; + if (centerX < -6.f) foundFarBehind = true; + if (centerX == -5.f) foundNearBehind = true; + if (centerX > 5.f) foundForward = true; + CHECK(runtime->completeGeneration(request.get(), &empty)); + } + CHECK(!foundFarBehind); + CHECK(foundNearBehind); + CHECK(foundForward); +} + +TEST_CASE("procgen.runtimeGeneration.rejectsStaleAsyncGenerationTickets") { + Procgen proc; + auto runtime = requireRuntime(proc, 77); + runtime->addLevel(10.f, 6.f, 1.5f); + runtime->updateSource(5.f, 5.f, 1.f, 0.f); + auto stale = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(stale)); + const uint64_t staleTicket = stale->getTicket(); + + runtime->updateSource(100.f, 100.f, 1.f, 0.f); + runtime->updateSource(5.f, 5.f, 1.f, 0.f); + auto current = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(current)); + CHECK_NE(current->getTicket(), staleTicket); + PointSet output; + CHECK(!runtime->completeGeneration(stale.get(), &output)); + CHECK(runtime->completeGeneration(current.get(), &output)); +} + +TEST_CASE("procgen.runtimeGeneration.rejectsStaleAsyncCleanupTickets") { + Procgen proc; + auto runtime = requireRuntime(proc, 78); + runtime->addLevel(10.f, 6.f, 1.5f); + runtime->updateSource(5.f, 5.f, 1.f, 0.f); + auto generated = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(generated)); + PointSet output; + CHECK(runtime->completeGeneration(generated.get(), &output)); + + runtime->updateSource(100.f, 100.f, 1.f, 0.f); + auto stale = ownRequest(runtime->nextCleanup()); + REQUIRE(bool(stale)); + runtime->updateSource(5.f, 5.f, 1.f, 0.f); + runtime->updateSource(100.f, 100.f, 1.f, 0.f); + CHECK(!runtime->completeCleanup(stale.get())); + auto current = ownRequest(runtime->nextCleanup()); + REQUIRE(bool(current)); + CHECK_NE(current->getTicket(), stale->getTicket()); + CHECK(runtime->completeCleanup(current.get())); +} + +TEST_CASE("procgen.runtimeGeneration.enforcesResidentCellReservations") { + Procgen proc; + auto runtime = requireRuntime(proc, 91); + runtime->addLevel(10.f, 30.f, 1.5f); + runtime->setMaxGenerating(100); + runtime->setMaxActiveCells(2); + CHECK_EQ(runtime->getMaxActiveCells(), 2); + runtime->updateSource(5.f, 5.f, 1.f, 0.f); + + auto first = ownRequest(runtime->nextGenerate()); + auto second = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(first)); + REQUIRE(bool(second)); + CHECK(!runtime->nextGenerate()); + CHECK_EQ(runtime->getGeneratingCount(), 2); + + PointSet empty; + CHECK(runtime->completeGeneration(first.get(), &empty)); + CHECK(runtime->completeGeneration(second.get(), &empty)); + CHECK_EQ(runtime->getActiveCellCount(), 2); + CHECK(!runtime->nextGenerate()); + CHECK(runtime->debugReport().find("maxActive=2") != std::string::npos); + + runtime->setMaxActiveCells(0); + CHECK_EQ(runtime->getMaxActiveCells(), 0); + auto unlimited = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(unlimited)); +} + +TEST_CASE("procgen.runtimeGeneration.enforcesPointMemoryBudgets") { + Procgen proc; + auto runtime = requireRuntime(proc, 92); + runtime->addLevel(10.f, 25.f, 1.5f); + runtime->setMaxGenerating(100); + runtime->setMaxPointsPerCell(2); + runtime->setMaxResidentPoints(2); + CHECK_EQ(runtime->getMaxPointsPerCell(), 2); + CHECK_EQ(runtime->getMaxResidentPoints(), 2); + runtime->updateSource(5.f, 5.f, 1.f, 0.f); + + auto first = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(first)); + PointSet oversized; + oversized.add(0.f, 0.f, 0.f); + oversized.add(1.f, 0.f, 0.f); + oversized.add(2.f, 0.f, 0.f); + CHECK(!runtime->completeGeneration(first.get(), &oversized)); + CHECK_EQ(runtime->getRejectedOutputCount(), 1); + CHECK(runtime->failGeneration(first.get())); + + auto retry = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(retry)); + PointSet accepted; + accepted.add(0.f, 0.f, 0.f); + accepted.add(1.f, 0.f, 0.f); + CHECK(runtime->completeGeneration(retry.get(), &accepted)); + CHECK_EQ(runtime->getResidentPointCount(), 2); + + auto second = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(second)); + PointSet one; + one.add(0.f, 0.f, 0.f); + CHECK(!runtime->completeGeneration(second.get(), &one)); + CHECK_EQ(runtime->getRejectedOutputCount(), 2); + CHECK_EQ(runtime->getPendingCleanupCount(), 1); + runtime->refreshGenerationSources(); + CHECK(!runtime->completeGeneration(second.get(), &one)); + CHECK_EQ(runtime->getRejectedOutputCount(), 3); + CHECK_EQ(runtime->getPendingCleanupCount(), 1); + auto cleanup = ownRequest(runtime->nextCleanup()); + REQUIRE(bool(cleanup)); + CHECK(runtime->completeCleanup(cleanup.get())); + CHECK_EQ(runtime->getResidentPointCount(), 0); + CHECK(runtime->completeGeneration(second.get(), &one)); + CHECK_EQ(runtime->getResidentPointCount(), 1); + CHECK(runtime->debugReport().find("residentPoints=1") != std::string::npos); +} + +TEST_CASE("procgen.runtimeGeneration.breaksEqualPriorityTiesDeterministically") { + Procgen proc; + auto first = requireRuntime(proc, 17); + auto second = requireRuntime(proc, 17); + for (RuntimeLease* runtime : {&first, &second}) { + (*runtime)->addLevel(10.f, 25.f, 1.5f); + (*runtime)->setMaxGenerating(100); + (*runtime)->updateSource(5.f, 5.f, 0.f, 0.f); + } + for (int index = 0; index < 12; ++index) { + auto a = ownRequest(first->nextGenerate()); + auto b = ownRequest(second->nextGenerate()); + REQUIRE(bool(a)); + REQUIRE(bool(b)); + CHECK_EQ(a->getLevel(), b->getLevel()); + CHECK_EQ(a->getX(), b->getX()); + CHECK_EQ(a->getZ(), b->getZ()); + } +} + +TEST_CASE("procgen.runtimeGeneration.trimsLowestPriorityCellsDeterministically") { + Procgen proc; + auto runtime = requireRuntime(proc, 18); + runtime->addLevel(10.f, 25.f, 1.5f); + runtime->setMaxGenerating(100); + runtime->updateSource(5.f, 5.f, 0.f, 0.f); + int farX = 0; + int farZ = 0; + PointSet one; + one.add(0.f, 0.f, 0.f); + for (int index = 0; index < 3; ++index) { + auto request = ownRequest(runtime->nextGenerate()); + REQUIRE(bool(request)); + farX = request->getX(); + farZ = request->getZ(); + CHECK(runtime->completeGeneration(request.get(), &one)); + } + CHECK_EQ(runtime->getResidentPointCount(), 3); + const int trimmedCells = runtime->trimToResidentPoints(2); + CHECK_EQ(trimmedCells, 1); + CHECK_EQ(runtime->getResidentPointCount(), 3); + auto cleanup = ownRequest(runtime->nextCleanup()); + REQUIRE(bool(cleanup)); + CHECK_EQ(cleanup->getX(), farX); + CHECK_EQ(cleanup->getZ(), farZ); + CHECK(runtime->completeCleanup(cleanup.get())); + CHECK_EQ(runtime->getResidentPointCount(), 2); +} + +TEST_CASE("procgen.runtimeGeneration.boundsRetriesAndExplicitlyRecoversFailures") { + RuntimeGeneration runtime(29); + runtime.addLevel(10.f, 6.f, 1.5f); + runtime.setMaxGenerationRetries(1); + CHECK_EQ(runtime.getMaxGenerationRetries(), 1); + runtime.updateSource(5.f, 5.f, 1.f, 0.f); + + ProcgenCellRequest* initial = runtime.nextGenerate(); + REQUIRE(bool(initial)); + CHECK(runtime.failGeneration(initial)); + delete initial; + CHECK_EQ(runtime.getFailedCellCount(), 0); + + ProcgenCellRequest* retry = runtime.nextGenerate(); + REQUIRE(bool(retry)); + CHECK(runtime.failGeneration(retry)); + delete retry; + CHECK_EQ(runtime.getFailedCellCount(), 1); + CHECK(runtime.debugReport().find("failed=1") != std::string::npos); + + const int retriedCells = runtime.retryFailedCells(); + CHECK_EQ(retriedCells, 1); + CHECK_EQ(runtime.getFailedCellCount(), 0); + ProcgenCellRequest* recovered = runtime.nextGenerate(); + REQUIRE(bool(recovered)); + PointSet empty; + CHECK(runtime.completeGeneration(recovered, &empty)); + CHECK_EQ(runtime.getActiveCellCount(), 1); + delete recovered; +} + +TEST_CASE("procgen.runtimeGeneration.persistsAttributedCellCachesAtomically") { + RuntimeGeneration source(404); + source.addLevel(10.f, 6.f, 1.5f); + source.updateSource(5.f, 5.f, 1.f, 0.f); + ProcgenCellRequest* generated = source.nextGenerate(); + REQUIRE(bool(generated)); + const int level = generated->getLevel(); + const int x = generated->getX(); + const int z = generated->getZ(); + PointSet points; + const int point = points.add(1.25f, 2.5f, 3.75f); + points.setNormal(point, 0.f, 0.f, 1.f); + points.setYaw(point, 45.f); + points.setScale(point, 2.f, 3.f, 4.f); + points.setDensity(point, 0.75f); + points.setPointSeed(point, 1234); + points.setFloatAttribute(point, "slope", 12.5f); + points.setFloatAttribute(point, "roughness", 0.4f); + points.setStringAttribute(point, "asset", "oak \"hero\""); + points.setStringAttribute(point, "biome", "forest"); + points.add(9.f, 8.f, 7.f); + CHECK(source.completeGeneration(generated, &points)); + delete generated; + + const std::string persisted = source.serializeCell(level, x, z); + CHECK(persisted.find("EVPCG_CELL 1 404") == 0); + RuntimeGeneration equivalent(404); + equivalent.addLevel(10.f, 6.f, 1.5f); + equivalent.updateSource(5.f, 5.f, 1.f, 0.f); + ProcgenCellRequest* equivalentRequest = equivalent.nextGenerate(); + REQUIRE(bool(equivalentRequest)); + PointSet equivalentPoints = points; + equivalentPoints.points()[0].floatAttributes.clear(); + equivalentPoints.points()[0].stringAttributes.clear(); + equivalentPoints.setFloatAttribute(0, "roughness", 0.4f); + equivalentPoints.setFloatAttribute(0, "slope", 12.5f); + equivalentPoints.setStringAttribute(0, "biome", "forest"); + equivalentPoints.setStringAttribute(0, "asset", "oak \"hero\""); + CHECK(equivalent.completeGeneration(equivalentRequest, &equivalentPoints)); + delete equivalentRequest; + CHECK_EQ(equivalent.serializeCell(level, x, z), persisted); + RuntimeGeneration restored(404); + restored.addLevel(10.f, 6.f, 1.5f); + CHECK(restored.deserializeCell(persisted)); + CHECK(restored.hasCell(level, x, z)); + CHECK_EQ(restored.getCellRevision(level, x, z), uint64_t(1)); + PointSet* loaded = restored.getCellOutput(level, x, z); + REQUIRE(bool(loaded)); + CHECK_EQ(loaded->getCount(), 2); + CHECK_EQ(loaded->getX(0), 1.25f); + CHECK_EQ(loaded->getNormalZ(0), 1.f); + CHECK_EQ(loaded->getScaleY(0), 3.f); + CHECK_EQ(loaded->getFloatAttribute(0, "slope", -1.f), 12.5f); + CHECK_EQ(loaded->getStringAttribute(0, "asset", ""), std::string("oak \"hero\"")); + delete loaded; + + CHECK(!restored.deserializeCell(persisted + "TRAILING")); + CHECK(restored.hasCell(level, x, z)); + RuntimeGeneration wrongWorld(405); + wrongWorld.addLevel(10.f, 6.f, 1.5f); + CHECK(!wrongWorld.deserializeCell(persisted)); + RuntimeGeneration constrained(404); + constrained.addLevel(10.f, 6.f, 1.5f); + constrained.setMaxPointsPerCell(1); + CHECK(!constrained.deserializeCell(persisted)); + CHECK_EQ(constrained.getRejectedOutputCount(), 1); +} diff --git a/test/procgen_scene_sink.cpp b/test/procgen_scene_sink.cpp new file mode 100644 index 00000000..351cfa12 --- /dev/null +++ b/test/procgen_scene_sink.cpp @@ -0,0 +1,154 @@ +#include "common/Capability.h" +#include "common/ProcgenSceneSink.h" +#include "procgen/Procgen.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include +#include +#include +#include + +using namespace eve::procgen; + +namespace { + +class MockProcgenSceneSink final : public eve::IProcgenSceneSink { +public: + bool applyBatch(const std::string& batchId, + const std::vector& instances) override { + const auto previous = batches.find(batchId); + std::unordered_map oldIds; + if (previous != batches.end()) + for (const auto& instance : previous->second) oldIds[instance.id] = true; + Stats stats; + for (const auto& instance : instances) { + if (oldIds.erase(instance.id) != 0) ++stats.reused; + else ++stats.created; + } + stats.removed = int(oldIds.size()); + batches[batchId] = instances; + latest[batchId] = stats; + return true; + } + bool removeBatch(const std::string& batchId) override { + const auto found = batches.find(batchId); + if (found == batches.end()) return false; + latest[batchId] = {0, 0, int(found->second.size())}; + batches.erase(found); + return true; + } + int instanceCount(const std::string& batchId) const override { + const auto found = batches.find(batchId); + return found == batches.end() ? 0 : int(found->second.size()); + } + int lastCreatedCount(const std::string& batchId) const override { + const auto found = latest.find(batchId); + return found == latest.end() ? 0 : found->second.created; + } + int lastReusedCount(const std::string& batchId) const override { + const auto found = latest.find(batchId); + return found == latest.end() ? 0 : found->second.reused; + } + int lastRemovedCount(const std::string& batchId) const override { + const auto found = latest.find(batchId); + return found == latest.end() ? 0 : found->second.removed; + } + + struct Stats { + int created = 0; + int reused = 0; + int removed = 0; + }; + std::unordered_map> batches; + std::unordered_map latest; +}; + +} // namespace + +TEST_CASE("procgen.sceneSink.publishesStableAttributedInstances") { + eve::cap::detail::clearAllRaw(); + MockProcgenSceneSink sink; + eve::cap::provide(&sink); + + Procgen proc; + auto pointsResult = proc.newPointSetHandle(); + REQUIRE(pointsResult.ok()); + const auto pointsHandle = std::move(pointsResult).takeValue(); + auto pointsView = proc.resolvePointSet(pointsHandle); + REQUIRE(pointsView.isBound()); + const int tree = pointsView->add(1.f, 2.f, 3.f); + pointsView->setPointSeed(tree, 77); + pointsView->setYaw(tree, 30.f); + pointsView->setScale(tree, 2.f, 3.f, 4.f); + pointsView->setStringAttribute(tree, "asset", "oak"); + const int rock = pointsView->add(8.f, 0.f, 9.f); + pointsView->setPointSeed(rock, 88); + + auto publishedResult = proc.publishInstances("forest/main", pointsHandle, "asset", "granite"); + REQUIRE(publishedResult.ok()); + CHECK_EQ(proc.getPublishedInstanceCount("forest/main"), 2); + CHECK_EQ(proc.getPublishedCreatedCount("forest/main"), 2); + CHECK_EQ(proc.getPublishedReusedCount("forest/main"), 0); + CHECK_EQ(proc.getPublishedRemovedCount("forest/main"), 0); + const auto& published = sink.batches.at("forest/main"); + CHECK_EQ(published[0].id, std::string("pcg-77-0")); + CHECK_EQ(published[0].asset, std::string("oak")); + CHECK_EQ(published[0].x, 1.f); + CHECK_EQ(published[0].yaw, 30.f); + CHECK_EQ(published[0].scaleZ, 4.f); + CHECK_EQ(published[1].asset, std::string("granite")); + CHECK_EQ(published[1].id, std::string("pcg-88-0")); + + auto reorderedResult = proc.newPointSetHandle(); + REQUIRE(reorderedResult.ok()); + const auto reorderedHandle = std::move(reorderedResult).takeValue(); + auto reorderedView = proc.resolvePointSet(reorderedHandle); + REQUIRE(reorderedView.isBound()); + reorderedView->add(-1.f, 0.f, 0.f); + reorderedView->setPointSeed(0, 99); + reorderedView->points().push_back(pointsView->points()[0]); + reorderedView->points().push_back(pointsView->points()[1]); + auto reorderedPublishResult = + proc.publishInstances("forest/main", reorderedHandle, "asset", "granite"); + REQUIRE(reorderedPublishResult.ok()); + CHECK_EQ(proc.getPublishedCreatedCount("forest/main"), 1); + CHECK_EQ(proc.getPublishedReusedCount("forest/main"), 2); + CHECK_EQ(proc.getPublishedRemovedCount("forest/main"), 0); + const auto& reconciled = sink.batches.at("forest/main"); + CHECK_EQ(reconciled[1].id, std::string("pcg-77-0")); + CHECK_EQ(reconciled[2].id, std::string("pcg-88-0")); + + reorderedView->setStringAttribute(0, "instanceId", "hero-tree"); + reorderedView->setStringAttribute(1, "instanceId", "hero-tree"); + auto duplicateResult = + proc.publishInstances("forest/main", reorderedHandle, "asset", "granite"); + REQUIRE(!duplicateResult.ok()); + CHECK(duplicateResult.status().describe().find("duplicate instance id") != std::string::npos); + CHECK_EQ(proc.getPublishedInstanceCount("forest/main"), 3); + + auto removeResult = proc.removeInstances("forest/main"); + REQUIRE(removeResult.ok()); + CHECK_EQ(proc.getPublishedInstanceCount("forest/main"), 0); + CHECK_EQ(proc.getPublishedRemovedCount("forest/main"), 3); + REQUIRE(proc.releasePointSet(reorderedHandle).ok()); + REQUIRE(proc.releasePointSet(pointsHandle).ok()); + eve::cap::revoke(&sink); +} + +TEST_CASE("procgen.sceneSink.reportsMissingProvider") { + eve::cap::detail::clearAllRaw(); + Procgen proc; + auto pointsResult = proc.newPointSetHandle(); + REQUIRE(pointsResult.ok()); + const auto pointsHandle = std::move(pointsResult).takeValue(); + auto pointsView = proc.resolvePointSet(pointsHandle); + REQUIRE(pointsView.isBound()); + pointsView->add(0.f, 0.f, 0.f); + + auto publishResult = proc.publishInstances("missing", pointsHandle, "asset", "tree"); + REQUIRE(!publishResult.ok()); + CHECK(publishResult.status().describe().find("unavailable") != std::string::npos); + REQUIRE(proc.releasePointSet(pointsHandle).ok()); +} diff --git a/test/procgen_shape_grammar.cpp b/test/procgen_shape_grammar.cpp new file mode 100644 index 00000000..fceb8f67 --- /dev/null +++ b/test/procgen_shape_grammar.cpp @@ -0,0 +1,78 @@ +#include "procgen/ShapeGrammar.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::procgen; + +TEST_CASE("procgen.shapeGrammar.expandsGroupsAndFillRepetition") { + ShapeGrammar grammar; + CHECK(grammar.addModule("A", "wall-a", 2.f, 1.f)); + CHECK(grammar.addModule("A", "wall-b", 2.f, 1.f)); + CHECK(!grammar.addModule("A", "wrong-length", 3.f, 1.f)); + CHECK(grammar.addModule("B", "post", 1.f, 1.f)); + CHECK(grammar.validate("A,[B,A]*")); + + PointSet spline; + spline.add(0.f, 0.f, 0.f); + spline.add(10.f, 0.f, 0.f); + PointSet* first = grammar.generate("A,[B,A]*", &spline, 42, false); + PointSet* second = grammar.generate("A,[B,A]*", &spline, 42, false); + REQUIRE(bool(first)); + REQUIRE(bool(second)); + CHECK_EQ(first->getCount(), 5); + CHECK_EQ(first->getStringAttribute(0, "module", ""), std::string("A")); + CHECK_EQ(first->getStringAttribute(1, "module", ""), std::string("B")); + CHECK_EQ(first->getStringAttribute(2, "module", ""), std::string("A")); + CHECK_EQ(first->getX(0), 1.f); + CHECK_EQ(first->getX(1), 2.5f); + CHECK_EQ(first->getX(2), 4.f); + CHECK_EQ(first->getYaw(0), 0.f); + CHECK_EQ(first->getFloatAttribute(1, "length", 0.f), 1.f); + for (int i = 0; i < first->getCount(); ++i) + CHECK_EQ(first->getStringAttribute(i, "asset", ""), + second->getStringAttribute(i, "asset", "")); + CHECK(grammar.debugReport().find("symbols=5") != std::string::npos); + + delete second; + delete first; +} + +TEST_CASE("procgen.shapeGrammar.supportsPlusExactAndIncompleteModes") { + ShapeGrammar grammar; + grammar.addModule("A", "wall", 2.f, 1.f); + grammar.addModule("B", "post", 1.f, 1.f); + PointSet spline; + spline.add(0.f, 0.f, 0.f); + spline.add(10.f, 0.f, 0.f); + + PointSet* plus = grammar.generate("[A,B]+", &spline, 1, false); + REQUIRE(bool(plus)); + CHECK_EQ(plus->getCount(), 6); + PointSet* exact = grammar.generate("A3,B2", &spline, 1, false); + REQUIRE(bool(exact)); + CHECK_EQ(exact->getCount(), 5); + + PointSet shortSpline; + shortSpline.add(0.f, 0.f, 0.f); + shortSpline.add(5.f, 0.f, 0.f); + CHECK(!grammar.generate("A3", &shortSpline, 1, false)); + CHECK(grammar.getError().find("does not fit") != std::string::npos); + PointSet* partial = grammar.generate("A3", &shortSpline, 1, true); + REQUIRE(bool(partial)); + CHECK_EQ(partial->getCount(), 2); + + delete partial; + delete exact; + delete plus; +} + +TEST_CASE("procgen.shapeGrammar.reportsSyntaxAndUnknownModules") { + ShapeGrammar grammar; + grammar.addModule("A", "wall", 1.f, 1.f); + CHECK(!grammar.validate("[A,A")); + CHECK(grammar.getError().find("unterminated") != std::string::npos); + CHECK(!grammar.validate("A,Missing")); + CHECK(grammar.getError().find("unknown module") != std::string::npos); + CHECK(!grammar.validate("")); +} diff --git a/test/procgen_spatial_data.cpp b/test/procgen_spatial_data.cpp new file mode 100644 index 00000000..5075d2a9 --- /dev/null +++ b/test/procgen_spatial_data.cpp @@ -0,0 +1,153 @@ +#include "procgen/Procgen.h" +#include "procgen/SpatialData.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include + +using namespace eve::procgen; + +TEST_CASE("procgen.spatialData.volumeComposition") { + const SpatialData world = SpatialData::box(0.f, 0.f, 0.f, 10.f, 4.f, 10.f); + const SpatialData hole = SpatialData::sphere(5.f, 2.f, 5.f, 2.f); + const SpatialData buildable = SpatialData::subtract(world, hole); + + CHECK_EQ(world.getKind(), std::string("volume.box")); + CHECK(world.contains(1.f, 1.f, 1.f)); + CHECK(!world.contains(11.f, 1.f, 1.f)); + CHECK(!buildable.contains(5.f, 2.f, 5.f)); + CHECK(buildable.contains(1.f, 1.f, 1.f)); + CHECK(buildable.hasBounds()); + CHECK_EQ(buildable.getMaxX(), 10.f); + + const SpatialData overlap = SpatialData::intersect( + world, SpatialData::box(8.f, -2.f, 8.f, 12.f, 8.f, 12.f)); + CHECK(overlap.contains(9.f, 2.f, 9.f)); + CHECK(!overlap.contains(7.f, 2.f, 9.f)); + CHECK_EQ(overlap.getMinX(), 8.f); + CHECK_EQ(overlap.getMaxZ(), 10.f); +} + +TEST_CASE("procgen.spatialData.splineAndDeterministicSampling") { + PointSet control; + control.add(0.f, 0.f, 0.f); + control.add(10.f, 0.f, 0.f); + const SpatialData road = SpatialData::spline(control, 1.f); + CHECK(road.contains(5.f, 0.5f, 0.f)); + CHECK(!road.contains(5.f, 2.f, 0.f)); + + const SpatialData volume = SpatialData::box(0.f, 0.f, 0.f, 4.f, 2.f, 4.f); + const PointSet first = volume.sample(2.f, 42, 0.f); + const PointSet second = volume.sample(2.f, 42, 0.f); + CHECK_EQ(first.getCount(), 18); + CHECK_EQ(first.getCount(), second.getCount()); + for (int i = 0; i < first.getCount(); ++i) { + CHECK_EQ(first.getX(i), second.getX(i)); + CHECK_EQ(first.getY(i), second.getY(i)); + CHECK_EQ(first.getZ(i), second.getZ(i)); + CHECK_EQ(first.getPointSeed(i), second.getPointSeed(i)); + } +} + +TEST_CASE("procgen.spatialData.scriptFacingApiFiltersPoints") { + Procgen proc; + auto includeResult = proc.boxVolumeHandle(0.f, -1.f, 0.f, 5.f, 1.f, 5.f); + auto excludeResult = proc.sphereVolumeHandle(2.f, 0.f, 2.f, 1.f); + REQUIRE(includeResult.ok()); + REQUIRE(excludeResult.ok()); + auto include = std::move(includeResult).takeValue(); + auto exclude = std::move(excludeResult).takeValue(); + auto domainResult = proc.differenceSpatialHandle(include, exclude); + REQUIRE(domainResult.ok()); + auto domain = std::move(domainResult).takeValue(); + + auto inputResult = proc.newPointSetHandle(); + REQUIRE(inputResult.ok()); + auto input = std::move(inputResult).takeValue(); + auto inputView = proc.resolvePointSet(input); + REQUIRE(inputView.isBound()); + inputView->add(1.f, 0.f, 1.f); + inputView->add(2.f, 0.f, 2.f); + inputView->add(9.f, 0.f, 9.f); + auto acceptedResult = proc.filterSpatialHandle(input, domain, false); + auto rejectedResult = proc.filterSpatialHandle(input, domain, true); + REQUIRE(acceptedResult.ok()); + REQUIRE(rejectedResult.ok()); + auto accepted = std::move(acceptedResult).takeValue(); + auto rejected = std::move(rejectedResult).takeValue(); + auto acceptedView = proc.resolvePointSet(accepted); + auto rejectedView = proc.resolvePointSet(rejected); + REQUIRE(acceptedView.isBound()); + REQUIRE(rejectedView.isBound()); + CHECK_EQ(acceptedView->getCount(), 1); + CHECK_EQ(rejectedView->getCount(), 2); + + REQUIRE(proc.releasePointSet(rejected).ok()); + REQUIRE(proc.releasePointSet(accepted).ok()); + REQUIRE(proc.releasePointSet(input).ok()); + REQUIRE(proc.release(domain).ok()); + REQUIRE(proc.release(exclude).ok()); + REQUIRE(proc.release(include).ok()); +} + +TEST_CASE("procgen.spatialData.heightfieldSamplesAndProjectsSurface") { + Heightmap map(3, 3); + for (int z = 0; z < 3; ++z) + for (int x = 0; x < 3; ++x) map.setHeight(x, z, float(x + z)); + + const SpatialData surface = SpatialData::heightfield(map, 10.f, 20.f, 2.f, 3.f); + CHECK_EQ(surface.getKind(), std::string("surface.heightfield")); + CHECK_EQ(surface.getMinX(), 10.f); + CHECK_EQ(surface.getMaxZ(), 24.f); + CHECK(surface.contains(12.f, 6.f, 22.f)); + + const PointSet samples = surface.sample(2.f, 91, 0.f); + CHECK_EQ(samples.getCount(), 9); + CHECK_EQ(samples.getX(0), 10.f); + CHECK_EQ(samples.getZ(0), 20.f); + CHECK_EQ(samples.getY(4), 6.f); + CHECK(samples.getNormalY(4) > 0.f); + CHECK(samples.getNormalX(4) < 0.f); + + PointSet source; + source.add(14.f, -100.f, 24.f); + const PointSet projected = surface.project(source); + CHECK_EQ(projected.getY(0), 12.f); +} + +TEST_CASE("procgen.pointOperations.matchCorePcgNodes") { + PointSet first; + const int a = first.add(1.f, 0.f, 0.f); + first.setDensity(a, 1.f); + first.setFloatAttribute(a, "age", 12.f); + first.setStringAttribute(a, "biome", "forest"); + + PointSet second; + const int b = second.add(0.f, 0.f, 2.f); + second.setDensity(b, 0.f); + second.setFloatAttribute(b, "age", 2.f); + second.setStringAttribute(b, "biome", "desert"); + + const PointSet merged = mergePointSets(first, second); + CHECK_EQ(merged.getCount(), 2); + const PointSet transformed = + transformPointSet(merged, 10.f, 3.f, 20.f, 90.f, 2.f, 1.f, 2.f); + CHECK(std::abs(transformed.getX(0) - 10.f) < 0.0001f); + CHECK(std::abs(transformed.getZ(0) - 22.f) < 0.0001f); + CHECK_EQ(transformed.getY(0), 3.f); + CHECK_EQ(transformed.getYaw(0), 90.f); + CHECK_EQ(transformed.getScaleX(0), 2.f); + + const PointSet mature = filterPointFloatAttribute(merged, "age", 10.f, 20.f, false); + const PointSet forest = filterPointStringAttribute(merged, "biome", "forest", false); + CHECK_EQ(mature.getCount(), 1); + CHECK_EQ(forest.getCount(), 1); + CHECK_EQ(forest.getStringAttribute(0, "biome", ""), std::string("forest")); + + const PointSet culledA = densityCullPoints(merged, 123, 1.f); + const PointSet culledB = densityCullPoints(merged, 123, 1.f); + CHECK_EQ(culledA.getCount(), 1); + CHECK_EQ(culledA.getCount(), culledB.getCount()); + CHECK_EQ(culledA.getPointSeed(0), culledB.getPointSeed(0)); +} diff --git a/test/profiler.cpp b/test/profiler.cpp new file mode 100644 index 00000000..30625268 --- /dev/null +++ b/test/profiler.cpp @@ -0,0 +1,75 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "common/Profile.h" +#include "profiler/Profiler.h" + +#include + +using eve::profiler::Profiler; + +TEST_CASE("profiler.core.disabled.isNoop") { + eve::prof::Profiler::reset(); + eve::prof::Profiler::setEnabled(false); + eve::prof::Profiler::zoneBegin("a"); + eve::prof::Profiler::zoneEnd(); + eve::prof::Profiler::frameMark(); + CHECK_NOT(eve::prof::Profiler::hasFrame()); + eve::prof::Profiler::reset(); +} + +TEST_CASE("profiler.core.aggregatesSelfAndTotal") { + eve::prof::Profiler::reset(); + eve::prof::Profiler::setEnabled(true); + eve::prof::Profiler::zoneBegin("parent", "physics"); + eve::prof::Profiler::zoneBegin("child", "physics"); + eve::prof::Profiler::zoneEnd(); + eve::prof::Profiler::zoneEnd(); + const double frameMs = eve::prof::Profiler::frameMark(); + CHECK_GT(frameMs, 0.0); + CHECK(eve::prof::Profiler::hasFrame()); + const auto& samples = eve::prof::Profiler::lastFrame(); + bool foundParent = false, foundChild = false; + for (const auto& s : samples) { + if (s.name == "parent" && s.module == "physics") { + foundParent = true; + // parent self time must be < its total time (a child ran inside it). + CHECK_LT(s.selfMs, s.totalMs); + } + if (s.name == "child") foundChild = true; + } + CHECK(foundParent); + CHECK(foundChild); + CHECK(eve::prof::Profiler::textReport().find("parent") != std::string::npos); + eve::prof::Profiler::setEnabled(false); + eve::prof::Profiler::reset(); +} + +TEST_CASE("profiler.module.frameCapture") { + auto* p = Profiler::create(); + p->reset(); + p->setEnabled(true); + p->beginFrame(); + p->begin("moduleScope"); + p->end(); + p->endFrame(); + CHECK(p->hasFrame()); + CHECK(p->frameMs() >= 0.f); + CHECK(p->textReport().find("moduleScope") != std::string::npos); + p->setEnabled(false); + p->reset(); +} + +TEST_CASE("profiler.module.renderPassZones") { + auto* p = Profiler::create(); + p->reset(); + p->setEnabled(true); + p->beginFrame(); + p->passBegin("ShadowPass"); // IRenderTracer pass -> graphics zone + p->passEnd("ShadowPass"); + p->endFrame(); + CHECK(p->hasFrame()); + CHECK(p->textReport().find("ShadowPass") != std::string::npos); + p->setEnabled(false); + p->reset(); +} diff --git a/test/profiler.nut b/test/profiler.nut new file mode 100644 index 00000000..fd89b755 --- /dev/null +++ b/test/profiler.nut @@ -0,0 +1,23 @@ +// Built-in profiler script API smoke test. +function captureWorks() { + local p = eve.Profiler(); + p.setEnabled(true); + p.beginFrame(); + p.begin("scriptScope"); + for (local i = 0; i < 1000; ++i) { /* busy */ } + p.end(); + p.endFrame(); + local rows = p.capture(); + if (typeof rows != "array") return false; + local found = false; + foreach (r in rows) { + if (typeof r != "table") return false; + if (!("name" in r) || !("selfMs" in r) || !("totalMs" in r)) return false; + if (r.name == "scriptScope") found = true; + } + if (!found) return false; + if (p.frameMs() < 0.0) return false; + if (p.textReport().find("scriptScope") == null) return false; + p.setEnabled(false); + return true; +} diff --git a/test/profiler_script.cpp b/test/profiler_script.cpp new file mode 100644 index 00000000..ad5e7050 --- /dev/null +++ b/test/profiler_script.cpp @@ -0,0 +1,8 @@ +#include "ScriptTest.h" + +extern const char* profiler_content; +UnitSciptTest(ProfilerScriptTest, profiler_content); + +TEST_CASE_FIXTURE(ProfilerScriptTest, "ProfilerScriptTest.capture") { + CHECK(vm.callFunc(vm.findFunc("captureWorks"), vm).toBool()); +} diff --git a/test/scene.cpp b/test/scene.cpp index c8c0ff32..07b8bacf 100644 --- a/test/scene.cpp +++ b/test/scene.cpp @@ -40,7 +40,9 @@ #include "window/Window.h" #include "common/ECS.h" +#include "common/Capability.h" #include "common/Module.h" +#include "common/ProcgenSceneSink.h" #include @@ -240,6 +242,61 @@ TEST_CASE("Scene.identity.mountFindAndOwnershipContracts") { CHECK(object.value() != nullptr); } +TEST_CASE("Scene.procgenSink.reconcilesAndClearsBatchHosts") { + Scene *mod = Scene::create(); + auto *sink = eve::cap::query(); + REQUIRE(sink != nullptr); + + std::vector instances(2); + instances[0].id = "tree-1"; + instances[0].asset = "oak"; + instances[0].x = 3.f; + instances[0].scaleY = 2.f; + instances[1].id = "rock-2"; + instances[1].asset = "granite"; + instances[1].z = 8.f; + CHECK(sink->applyBatch("biome/0/0", instances)); + CHECK_EQ(sink->instanceCount("biome/0/0"), 2); + CHECK_EQ(sink->lastCreatedCount("biome/0/0"), 2); + CHECK_EQ(sink->lastReusedCount("biome/0/0"), 0); + + auto hostResult = mod->findHost("__pcg/biome/0/0"); + REQUIRE(hostResult.ok()); + SceneHost *host = hostResult.value(); + REQUIRE(host != nullptr); + CHECK_EQ(host->getNodeCount(), 3); + auto treeResult = host->findById("tree-1"); + REQUIRE(treeResult.ok()); + SceneNode *tree = treeResult.value(); + REQUIRE(tree != nullptr); + CHECK(approxEq(tree->x, 3.f)); + CHECK(approxEq(tree->sy, 2.f)); + CHECK(host->hasTag(tree, "pcg.asset:oak")); + auto *pooledRenderable = eve::graphics::Renderable3D::create(); + REQUIRE(host->linkRenderable3D("tree-1", pooledRenderable)); + + instances.resize(1); + instances[0].x = 7.f; + CHECK(sink->applyBatch("biome/0/0", instances)); + CHECK_EQ(sink->instanceCount("biome/0/0"), 1); + CHECK_EQ(host->getNodeCount(), 2); + auto updatedTreeResult = host->findById("tree-1"); + REQUIRE(updatedTreeResult.ok()); + CHECK(approxEq(updatedTreeResult.value()->x, 7.f)); + auto removedRockResult = host->findById("rock-2"); + CHECK(!removedRockResult.ok()); + CHECK_EQ(host->linkCount("tree-1"), 1); + CHECK_EQ(sink->lastCreatedCount("biome/0/0"), 0); + CHECK_EQ(sink->lastReusedCount("biome/0/0"), 1); + CHECK_EQ(sink->lastRemovedCount("biome/0/0"), 1); + + CHECK(sink->removeBatch("biome/0/0")); + CHECK_EQ(sink->lastRemovedCount("biome/0/0"), 1); + ecs::DestroyEntity(pooledRenderable); + CHECK_EQ(sink->instanceCount("biome/0/0"), 0); + CHECK_EQ(host->getNodeCount(), 1); +} + TEST_CASE("Scene.link.syncRenderable3DWorld") { SceneHost *h = sceneValue(SceneHost::createHost("link3d")); h->setTree(node("root", {node("mesh").withPosition(1.f, 2.f, 3.f)}).withPosition(10.f, 0.f, 0.f)); diff --git a/test/schema.cpp b/test/schema.cpp index b7c9f69f..294d358e 100644 --- a/test/schema.cpp +++ b/test/schema.cpp @@ -196,3 +196,47 @@ TEST_CASE("schema.module.scriptBinding") { vm.run(script); CHECK(vm.callFunc(vm.findFunc("schemaBindingTest"), vm).toBool()); } + +TEST_CASE("schema.module.reflectFromClass") { + SchemaRegistry::clear(); + ssq::VM vm(1024, ssq::Libs::ALL); + eve::ModuleManager::expose(vm); + auto script = vm.compileSource(R"( + class CardDefinition { + + id = "" + + name = "" + + kind = "creature" + + cost = 0 + + attack = 0 + + health = 0 + } + + function schemaReflectTest() { + local s = eve.Schema(); + s.clear(); + if (!s.registerFromClass(CardDefinition)) return s.getLastError(); + if (s.getSchemaVersion("card.definition") != 1) return "version"; + if (s.getSchemaTitle("card.definition") != "Card Definition") return "title"; + if (s.getSchemaAdditionalProperties("card.definition")) return "additionalProperties"; + if (s.getFieldCount("card.definition") != 6) return "count"; + if (s.getFieldType("card.definition", 3) != "integer") return "type"; + if (!s.getFieldRequired("card.definition", 0)) return "required"; + if (s.getFieldEnumCount("card.definition", 2) != 2) return "enum"; + if (!s.getFieldHasMinimum("card.definition", 3)) return "min"; + if (s.getFieldMaximum("card.definition", 3) != 20) return "max"; + local ok = @"{""id"":""card.scout"",""name"":""Scout"",""kind"":""creature"",""cost"":2,""attack"":3,""health"":2}"; + if (!s.validateJson("card.definition", ok)) return "valid"; + local bad = @"{""id"":""x"",""name"":""y"",""kind"":""air"",""cost"":25}"; + if (s.validateJson("card.definition", bad)) return "invalid"; + return s.getValidationErrorCode(0) == "enum"; + } + )"); + vm.run(script); + CHECK(vm.callFunc(vm.findFunc("schemaReflectTest"), vm).toBool()); +} diff --git a/test/script_compiler.cpp b/test/script_compiler.cpp index 4b867ed6..47b4f554 100644 --- a/test/script_compiler.cpp +++ b/test/script_compiler.cpp @@ -53,13 +53,11 @@ TEST_CASE("scriptCompiler.mapsDebuggerLocationsBidirectionally") { map.entries.push_back({{10, 1}, {3, 1}}); REQUIRE(runtime.scriptCompiler().setSourceMap("game:/scripts/mapped.nut", std::move(map))); - const auto original = script::ScriptCompiler::toOriginalPosition( - "C:/games/demo/scripts/mapped.nut", {12, 7}); + const auto original = script::ScriptCompiler::toOriginalPosition("C:/games/demo/scripts/mapped.nut", {12, 7}); CHECK_EQ(original.line, uint32_t(5)); CHECK_EQ(original.column, uint32_t(7)); - const auto generated = script::ScriptCompiler::toGeneratedPosition( - "game:/scripts/mapped.nut", {5, 7}); + const auto generated = script::ScriptCompiler::toGeneratedPosition("game:/scripts/mapped.nut", {5, 7}); CHECK_EQ(generated.line, uint32_t(12)); CHECK_EQ(generated.column, uint32_t(7)); } @@ -111,6 +109,24 @@ TEST_CASE("scriptCompiler.bindingContractEnablesNativeNamedArguments") { CHECK_EQ(result, int64_t(123)); } +TEST_CASE("scriptCompiler.generatedContractsDriveSimpleSquirrelNamedArguments") { + Runtime runtime(512, ssq::Libs::ALL); + const auto contracts = runtime.scriptCompiler().bindings().snapshot(); + CHECK(contracts.size() > size_t(100)); + + const script::BindingContract* generated = + runtime.scriptCompiler().bindings().find("animation/AnimRetargetProfile.setNormalizedNameMatching"); + REQUIRE(generated != nullptr); + REQUIRE_EQ(generated->parameters.size(), size_t(1)); + CHECK_EQ(generated->parameters[0].name, std::string("enabled")); + CHECK_EQ(generated->parameters[0].type, std::string("bool")); + + runtime.vm().addFunc("setNormalizedNameMatching", [](bool enabled) { return enabled; }); + runtime.runSource("generated_contract_result <- setNormalizedNameMatching(enabled: true)\n", + "generated-contract-test.nut"); + CHECK(runtime.vm().get("generated_contract_result")); +} + TEST_CASE("scriptCompiler.bindingContractConvertsUnitLiterals") { Runtime runtime(512, ssq::Libs::ALL); runtime.vm().addFunc("nativeAngle", [](float angle) { return angle; }); @@ -219,22 +235,22 @@ TEST_CASE("scriptCompiler.servesLanguageToolingQueries") { runtime.compileSource("local playerSpeed: float = 2.0\n", "game:/tooling.nut"); script::BindingContract contract; contract.module = "physics"; - contract.method = "newWorld"; + contract.method = "toolingNewWorld"; contract.returnType = "World"; - contract.documentationId = "physics.newWorld"; + contract.documentationId = "physics.toolingNewWorld"; contract.parameters = {{"gravity", "float"}, {"sleep", "bool"}}; runtime.scriptCompiler().bindings().registerContract(std::move(contract)); const auto scriptItems = runtime.scriptCompiler().completions("game:/tooling.nut", "player"); CHECK_EQ(scriptItems.size(), size_t(1)); CHECK_EQ(scriptItems[0].label, std::string("playerSpeed")); - const auto nativeItems = runtime.scriptCompiler().completions("game:/tooling.nut", "new"); + const auto nativeItems = runtime.scriptCompiler().completions("game:/tooling.nut", "toolingNew"); CHECK_EQ(nativeItems.size(), size_t(1)); CHECK(nativeItems[0].insertText.find("gravity:") != std::string::npos); - const auto hover = runtime.scriptCompiler().hover("game:/tooling.nut", "newWorld"); - CHECK(hover.has_value()); - CHECK(hover->markdown.find("physics.newWorld") != std::string::npos); + const auto hover = runtime.scriptCompiler().hover("game:/tooling.nut", "toolingNewWorld"); + REQUIRE(hover.has_value()); + CHECK(hover->markdown.find("physics.toolingNewWorld") != std::string::npos); CHECK(runtime.scriptCompiler().diagnostics("game:/tooling.nut").empty()); } @@ -271,6 +287,20 @@ TEST_CASE("scriptCompiler.bindingContractChecksLiteralTypes") { CHECK(rejected); } +TEST_CASE("scriptCompiler.bindingContractScopesLiteralChecksToWholeArgument") { + Runtime runtime(512, ssq::Libs::ALL); + runtime.vm().addFunc("nativeText", [](std::string text) { return text; }); + script::BindingContract contract; + contract.module = "test"; + contract.method = "nativeText"; + contract.returnType = "string"; + contract.parameters = {{"text", "string", false}}; + runtime.scriptCompiler().bindings().registerContract(std::move(contract)); + + runtime.runSource("compound_contract_result <- nativeText(\"count \" + (1 + 2))\n", "game:/compound-contract.nut"); + CHECK_EQ(runtime.vm().get("compound_contract_result"), std::string("count 3")); +} + TEST_CASE("scriptCompiler.validatesConfigModulesWithoutExecution") { const auto diagnostics = script::ScriptCompiler::validateProjectConfig( "config <- { modules = [\"gfx\", \"physics\", \"typo\"], optionalModules = [\"audio\"] }\n", "game:/config.nut", @@ -285,7 +315,7 @@ TEST_CASE("scriptCompiler.validatesConfigModulesWithoutExecution") { TEST_CASE("scriptCompiler.compilesRepositoryNutCompatibilityBaseline") { #if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) const std::filesystem::path root(EVENGINE_SOURCE_DIR); - size_t compiled = 0; + size_t compiled = 0; for (std::filesystem::recursive_directory_iterator it(root), end; it != end; ++it) { if (it->is_directory()) { const std::string name = it->path().filename().string(); diff --git a/test/script_module.cpp b/test/script_module.cpp index 891567bf..6387d497 100644 --- a/test/script_module.cpp +++ b/test/script_module.cpp @@ -1,5 +1,5 @@ -#include "common/Runtime.h" #include "common/Capability.h" +#include "common/Runtime.h" #include "common/ScriptCompiler.h" #include "common/ScriptModule.h" #include "common/ServiceInterfaces.h" @@ -150,3 +150,42 @@ TEST_CASE("scriptModule.reloadReplacesGenerationAtomically") { "game:/new-generation.nut"); CHECK_EQ(runtime.vm().get("new_generation"), int64_t(2)); } + +TEST_CASE("scriptModule.reloadAffectedRebuildsReverseDependencyGenerations") { + Runtime runtime(512, ssq::Libs::ALL); + auto provider = std::make_shared(); + provider->sources["mem:/value.nut"] = "export function value() { return 1 }\n"; + provider->sources["mem:/user.nut"] = + "import { value } from \"mem:/value.nut\"\nexport function answer() { return value() }\n"; + runtime.scriptModules().registerProvider(provider, 100); + runtime.runSource("import { answer } from \"mem:/user.nut\"\nfirst_answer <- answer()\n", "game:/first.nut"); + CHECK_EQ(runtime.vm().get("first_answer"), int64_t(1)); + + provider->sources["mem:/value.nut"] = "export function value() { return 2 }\n"; + const auto affected = runtime.scriptModules().reloadAffected("mem:/value.nut"); + CHECK_EQ(affected.size(), size_t(2)); + runtime.runSource("import { answer } from \"mem:/user.nut\"\nsecond_answer <- answer()\n", "game:/second.nut"); + CHECK_EQ(runtime.vm().get("second_answer"), int64_t(2)); +} + +TEST_CASE("scriptModule.reloadAffectedRollsBackWholeGenerationGroup") { + Runtime runtime(512, ssq::Libs::ALL); + auto provider = std::make_shared(); + provider->sources["mem:/value.nut"] = "export function value() { return 3 }\n"; + provider->sources["mem:/user.nut"] = + "import { value } from \"mem:/value.nut\"\nexport function answer() { return value() }\n"; + runtime.scriptModules().registerProvider(provider, 100); + runtime.runSource("import { answer } from \"mem:/user.nut\"\nbefore_failure <- answer()\n", "game:/before.nut"); + + provider->sources["mem:/value.nut"] = "export function value() { return 4 }\n"; + provider->sources["mem:/user.nut"] = "export function answer( {\n"; + bool failed = false; + try { + runtime.scriptModules().reloadAffected("mem:/value.nut"); + } catch (const std::exception&) { + failed = true; + } + REQUIRE(failed); + runtime.runSource("import { answer } from \"mem:/user.nut\"\nafter_failure <- answer()\n", "game:/after.nut"); + CHECK_EQ(runtime.vm().get("after_failure"), int64_t(3)); +} diff --git a/test/template.cpp.in b/test/template.cpp.in index 76cbb91f..6c12f2f1 100644 --- a/test/template.cpp.in +++ b/test/template.cpp.in @@ -13,3 +13,4 @@ const char* crowd_content = R"content(@crowd_content@)content"; const char* i18n_content = R"content(@i18n_content@)content"; const char* dialogue_proc_script_content = R"content(@dialogue_proc_script_content@)content"; const char* physics_cloth_content = R"content(@physics_cloth_content@)content"; +const char* profiler_content = R"content(@profiler_content@)content";