diff --git a/Apps/CMakeLists.txt b/Apps/CMakeLists.txt index 51b2e2ec7a..3fcb93aa61 100644 --- a/Apps/CMakeLists.txt +++ b/Apps/CMakeLists.txt @@ -7,6 +7,18 @@ if(NOT ANDROID) add_subdirectory(Playground) endif() +# ShaderCacheGenerator: Win32 + OpenGL headless tool that renders a scene .js +# until its shaders are compiled, then writes the ShaderCache to a file. +# Configure with -DGRAPHICS_API=OpenGLWindowsDevOnly (which maps to OpenGL + +# ANGLE). Needs the ShaderCache plugin, the runtime shader compiler +# (COMPILESHADERS), and the Embedding Runtime/View API it is built on. +if((WIN32 AND NOT WINDOWS_STORE) AND GRAPHICS_API STREQUAL OpenGL + AND BABYLON_NATIVE_EMBEDDING + AND BABYLON_NATIVE_PLUGIN_SHADERCACHE + AND BABYLON_NATIVE_PLUGIN_NATIVEENGINE_COMPILESHADERS) + add_subdirectory(ShaderCacheGenerator) +endif() + if((WIN32 AND NOT WINDOWS_STORE) OR (APPLE AND NOT IOS AND NOT VISIONOS) OR (UNIX AND NOT ANDROID AND NOT APPLE)) add_subdirectory(UnitTests) add_subdirectory(ModuleLoadTest) diff --git a/Apps/ShaderCacheGenerator/CMakeLists.txt b/Apps/ShaderCacheGenerator/CMakeLists.txt new file mode 100644 index 0000000000..a9a73ac27c --- /dev/null +++ b/Apps/ShaderCacheGenerator/CMakeLists.txt @@ -0,0 +1,78 @@ +# ShaderCacheGenerator +# +# A headless Win32 + OpenGL tool that builds a GPU shader cache for a Babylon.js +# scene. It hosts the scene script through the Babylon::Embedding Runtime/View +# API (backed by an offscreen window + the ANGLE OpenGL backend), renders frames +# until the scene has finished compiling its shaders, and writes the resulting +# ShaderCache to a file. +# +# Usage: +# ShaderCacheGenerator [shared Playground options] +# +# Only the two positional arguments (scene script + output cache file) are +# required; any additional flags are the shared Playground command-line options +# (run with --help to list them). +# +# The produced cache is GRAPHICS_API-specific: configuring with +# -DGRAPHICS_API=OpenGLWindowsDevOnly (which maps to the OpenGL/ANGLE backend) +# yields GLES shaders compatible with the Android OpenGL backend. + +set(PLAYGROUND_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../Playground") + +set(SOURCES + "Source/Main.cpp") + +# Reused from Apps/Playground/Shared: command-line parsing, process diagnostics +# (console/crash/exit handling) and the log callback. +set(SHARED_SOURCES + "${PLAYGROUND_DIR}/Shared/CommandLine.cpp" + "${PLAYGROUND_DIR}/Shared/CommandLine.h" + "${PLAYGROUND_DIR}/Shared/Diagnostics.cpp" + "${PLAYGROUND_DIR}/Shared/Diagnostics.h" + "${PLAYGROUND_DIR}/Shared/PlaygroundScripts.cpp" + "${PLAYGROUND_DIR}/Shared/PlaygroundScripts.h") + +add_executable(ShaderCacheGenerator ${SOURCES} ${SHARED_SOURCES}) + +warnings_as_errors(ShaderCacheGenerator) + +target_compile_definitions(ShaderCacheGenerator + PRIVATE UNICODE + PRIVATE _UNICODE) + +# So Main.cpp can include the reused headers as etc. +target_include_directories(ShaderCacheGenerator PRIVATE "${PLAYGROUND_DIR}") + +# SUBSYSTEM:CONSOLE so stdout/stderr behave like a normal console app; the tool +# still creates a hidden Win32 window for the GL swapchain. +set_target_properties(ShaderCacheGenerator PROPERTIES + LINK_FLAGS "/SUBSYSTEM:CONSOLE") + +# Embedding transitively provides the polyfills/plugins (NativeEngine, +# ShaderCache, ...) and their include paths. ShaderCache is listed explicitly +# because Main.cpp includes and calls Save(). +# bx / Foundation are required by the reused Shared sources (Diagnostics.cpp, +# PlaygroundScripts.cpp). Shlwapi provides UrlCreateFromPathA for the file:// +# script URL. +target_link_libraries(ShaderCacheGenerator + PRIVATE Embedding + PRIVATE ShaderCache + PRIVATE bx + PRIVATE Foundation + PRIVATE Shlwapi) + +# Copy transitive runtime DLLs next to the executable. +add_custom_command(TARGET ShaderCacheGenerator POST_BUILD + COMMAND ${CMAKE_COMMAND} -E $>,copy,true> $ $ COMMAND_EXPAND_LISTS) + +# Copy the ANGLE GL DLLs so the OpenGL backend can load at runtime. +if(ANGLE_LIBEGL) + add_custom_command(TARGET ShaderCacheGenerator POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ANGLE_LIBEGL}" "$" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ANGLE_LIBGLESV2}" "$" + COMMENT "Copying ANGLE libraries to ShaderCacheGenerator output directory") +endif() + +set_property(TARGET ShaderCacheGenerator PROPERTY FOLDER Apps) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) +source_group("Shared" FILES ${SHARED_SOURCES}) diff --git a/Apps/ShaderCacheGenerator/README.md b/Apps/ShaderCacheGenerator/README.md new file mode 100644 index 0000000000..5708946e6c --- /dev/null +++ b/Apps/ShaderCacheGenerator/README.md @@ -0,0 +1,47 @@ +# ShaderCacheGenerator + +Headless Win32 + OpenGL tool that builds a GPU shader cache for a Babylon.js +scene. It hosts the scene through the `Babylon::Embedding` Runtime/View API on a +hidden window (ANGLE OpenGL backend), renders frames until the scene's shaders +are compiled, then writes the `ShaderCache` to a file. + +Command-line parsing, process diagnostics (console/crash/exit handling) and the +log callback are reused from `Apps/Playground/Shared`. + +## Usage + +``` +ShaderCacheGenerator [shared options] +``` + +| Argument | Description | +| -------------- | -------------------------------------- | +| `` | Scene script to load (required). | +| `` | Shader cache file to write (required). | + +`[shared options]` are the Playground command-line flags (run with `--help` to +list them); e.g. `--debug-trace=true`. + +Exit codes: `0` success (>=1 shader written), `2` command-line / input error, +`1` runtime failure, `3` hard crash. + +## Build + +Configure BabylonNative for Win32 with the ANGLE OpenGL backend, then build the +target: + +``` +cmake -B build -G "Visual Studio 18 2026" -A x64 -D GRAPHICS_API=OpenGLWindowsDevOnly -D BABYLON_NATIVE_BUILD_APPS=ON +cmake --build build --target ShaderCacheGenerator --config Release +``` + +Requires an installed Edge/Chrome (for `libEGL.dll` / `libGLESv2.dll`). The +cache is GL-specific, so it is compatible with the Android OpenGL backend. + +## "Scene ready" detection + +There is no fixed frame count. The tool renders until the shader-cache entry +count stops growing for a number of consecutive checks -- i.e. the scene has +finished compiling every effect it uses. This is the meaningful signal for a +cache generator and works for any scene without it exposing globals. A frame cap +is applied only as a safety net. diff --git a/Apps/ShaderCacheGenerator/Source/Main.cpp b/Apps/ShaderCacheGenerator/Source/Main.cpp new file mode 100644 index 0000000000..9d8f8c3d6c --- /dev/null +++ b/Apps/ShaderCacheGenerator/Source/Main.cpp @@ -0,0 +1,312 @@ +// ShaderCacheGenerator +// +// Headless Win32 + OpenGL tool that builds a GPU shader cache for a Babylon.js +// scene. It hosts the scene through the Babylon::Embedding Runtime/View API (an +// offscreen window driving the ANGLE OpenGL backend), renders frames until the +// scene has finished compiling its shaders ("scene is ready"), then writes the +// ShaderCache to a file and exits. +// +// Usage: +// ShaderCacheGenerator [shared options] +// +// Command-line parsing, process diagnostics (console/crash/exit handling) and +// the log callback are reused from Apps/Playground/Shared. The two positional +// arguments are the scene script and the output cache file. +// +// "Scene ready" is defined as: the shader cache entry count has stopped growing +// for `kStableChecks` consecutive checks. For a shader-cache generator this is +// exactly the meaningful signal -- every effect the scene uses has been +// compiled (and therefore cached). + +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + // Offscreen render target size. + constexpr uint32_t kWidth = 1024; + constexpr uint32_t kHeight = 1024; + + // Readiness heuristic: render until the cached-shader count is unchanged for + // kStableChecks consecutive checks (sampled every kCheckInterval frames), + // capped at kMaxFrames. + constexpr uint32_t kCheckInterval = 10; + constexpr uint32_t kStableChecks = 15; + constexpr uint32_t kMaxFrames = 3000; + + void PrintUsage(const char* exe) + { + std::cerr << "Usage: " << (exe != nullptr ? exe : "ShaderCacheGenerator") + << " [shared options]\n" + << " Babylon.js scene script to load and render.\n" + << " Shader cache file to write.\n" + << "Shared options (see below) are parsed by the Playground command line,\n" + << "e.g. --debug-trace=true.\n\n"; + CommandLine::PrintUsage(exe); + } + + // Build a file:// URL for an absolute path so ScriptLoader/File can fetch it. + // Build a file:// URL for an absolute path so ScriptLoader/File can fetch it. + // Mirrors the Win32 Playground helper: feed UrlCreateFromPathA the UTF-8 + // bytes of the path and return the NUL-terminated result (ignoring the + // API-reported length, which may include the terminator). + std::string GetUrlFromPath(const std::filesystem::path& path) + { + const std::u8string utf8 = path.u8string(); + char url[2048]; + DWORD length = ARRAYSIZE(url); + if (FAILED(UrlCreateFromPathA(reinterpret_cast(utf8.c_str()), url, &length, 0))) + { + throw std::runtime_error("Failed to create URL from path."); + } + return std::string{url}; + } + + LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) + { + return DefWindowProc(hWnd, message, wParam, lParam); + } + + // Create a hidden top-level window with a real client area for the GL + // swapchain. Not shown -- the tool is headless. (No Playground/Shared helper + // exists for this; the Embedding View requires a platform window handle.) + HWND CreateOffscreenWindow(HINSTANCE instance, uint32_t width, uint32_t height) + { + static const wchar_t* className = L"ShaderCacheGeneratorWindow"; + + WNDCLASSEXW wcex{}; + wcex.cbSize = sizeof(WNDCLASSEXW); + wcex.lpfnWndProc = WndProc; + wcex.hInstance = instance; + wcex.hCursor = LoadCursor(nullptr, IDC_ARROW); + wcex.lpszClassName = className; + RegisterClassExW(&wcex); + + return CreateWindowW( + className, L"ShaderCacheGenerator", WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, 0, static_cast(width), static_cast(height), + nullptr, nullptr, instance, nullptr); + } + + void PumpMessages() + { + MSG msg{}; + while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + } + + // A std::streambuf that discards everything written to it. Used as the sink + // for ShaderCache::Save when we only need the returned entry count -- Save + // serializes the whole cache (shader bytecode included), so an ostringstream + // would repeatedly allocate large buffers during the polling loop. + class NullBuffer : public std::streambuf + { + public: + int overflow(int c) override { return c; } + std::streamsize xsputn(const char*, std::streamsize n) override { return n; } + }; + + // Read the current shader-cache entry count. The runtime is suspended first + // so the JS thread's in-flight frame is closed and no shader compilation is + // in progress while ShaderCache::Save walks the cache. + uint32_t CountShaderCacheEntries(Babylon::Embedding::Runtime& runtime) + { + runtime.Suspend(); + NullBuffer nullBuffer; + std::ostream sink{&nullBuffer}; + const uint32_t count = Babylon::Plugins::ShaderCache::Save(sink); + runtime.Resume(); + return count; + } +} + +int main(int argc, char** argv) +{ + // Console setup, crash handler, ANSI colors and the atexit finish-line hook. + Diagnostics::Initialize(); + + const char* argv0 = argc > 0 ? argv[0] : nullptr; + + PlaygroundOptions options = CommandLine::Parse(argc, argv); + if (options.ShowHelp) + { + PrintUsage(argv0); + Diagnostics::SetExitCode(0); + return 0; + } + if (options.ParseError) + { + std::cerr << "Error: " << options.ErrorMessage << "\n\n"; + PrintUsage(argv0); + Diagnostics::SetExitCode(2); + return 2; + } + if (options.Scripts.size() != 2) + { + std::cerr << "Error: expected exactly two positional arguments " + "( ).\n\n"; + PrintUsage(argv0); + Diagnostics::SetExitCode(2); + return 2; + } + + const std::string scriptArg = options.Scripts[0]; + const std::filesystem::path outputPath = std::filesystem::path(options.Scripts[1]); + + std::error_code ec; + const std::filesystem::path scriptAbsolute = + std::filesystem::absolute(std::filesystem::path(scriptArg), ec); + if (ec || !std::filesystem::exists(scriptAbsolute)) + { + std::cerr << "Scene script not found: " << scriptArg << "\n"; + Diagnostics::SetExitCode(2); + return 2; + } + + std::string scriptUrl; + try + { + scriptUrl = GetUrlFromPath(scriptAbsolute); + } + catch (const std::exception& e) + { + std::cerr << e.what() << "\n"; + Diagnostics::SetExitCode(2); + return 2; + } + + const HINSTANCE instance = GetModuleHandleW(nullptr); + const HWND window = CreateOffscreenWindow(instance, kWidth, kHeight); + if (window == nullptr) + { + std::cerr << "Failed to create offscreen window.\n"; + Diagnostics::SetExitCode(1); + return 1; + } + + std::cout << "ShaderCacheGenerator\n" + << " script : " << scriptAbsolute.string() << "\n" + << " output : " << outputPath.string() << "\n" + << " size : " << kWidth << "x" << kHeight << "\n"; + + // PerfTrace and other process-wide Playground settings. + Playground::Initialize(options); + + uint32_t finalCount = 0; + { + Babylon::Embedding::RuntimeOptions runtimeOptions{}; + runtimeOptions.enableDebugTrace = options.DebugTrace.value_or(false); + // Do NOT set shaderCachePath: we manage Save() ourselves so the cache is + // written exactly once, after the scene is ready. The Embedding layer + // still enables the ShaderCache on the first View attach, so every + // compiled shader is captured. + runtimeOptions.log = Playground::MakeLogCallback([](std::string_view text) { + std::string line{text}; + line.push_back('\n'); + OutputDebugStringA(line.c_str()); + std::fputs(line.c_str(), stdout); + }); + + Babylon::Embedding::Runtime runtime{std::move(runtimeOptions)}; + runtime.LoadScript(scriptUrl); + + Babylon::Embedding::View view{runtime, window}; + view.Resize(kWidth, kHeight, Babylon::Embedding::CoordinateUnits::Physical); + + uint32_t lastCount = 0; + uint32_t stable = 0; + bool ready = false; + + uint32_t frame = 0; + for (; frame < kMaxFrames && !ready; ++frame) + { + PumpMessages(); + view.RenderFrame(); + ::Sleep(5); // give the JS thread time to run the render loop / compile shaders + + const bool isCheckFrame = + frame >= kCheckInterval && + (frame % kCheckInterval) == 0 && + Babylon::Plugins::ShaderCache::IsEnabled(); + + if (isCheckFrame) + { + const uint32_t count = CountShaderCacheEntries(runtime); + const bool grew = (count != lastCount); + if (count > 0 && !grew) + { + ++stable; + if (stable >= kStableChecks) + { + ready = true; + } + } + else + { + stable = 0; + lastCount = count; + } + std::cout << " frame " << frame << ": " << count + << " shader(s) cached " + << (grew ? std::string{"(growing)"} + : "(stable " + std::to_string(stable) + "/" + + std::to_string(kStableChecks) + ")") + << "\n"; + } + } + + if (ready) + { + std::cout << "Scene ready after " << frame << " frames.\n"; + } + else + { + std::cout << "Reached frame cap (" << kMaxFrames + << ") before the shader cache stabilized; saving anyway.\n"; + } + + // Suspend once more so the write happens on a quiescent engine, then + // persist the cache while it is still enabled (before ~Runtime). + runtime.Suspend(); + std::ofstream outputFile{outputPath, std::ios::binary | std::ios::trunc}; + if (!outputFile.is_open()) + { + std::cerr << "Failed to open output file for writing: " + << outputPath.string() << "\n"; + Diagnostics::SetExitCode(1); + return 1; + } + finalCount = Babylon::Plugins::ShaderCache::Save(outputFile); + outputFile.close(); + } + + if (finalCount == 0) + { + std::cerr << "No shaders were cached. The scene may not have rendered " + "anything, or shader compilation is disabled in this build.\n"; + Diagnostics::SetExitCode(1); + return 1; + } + + std::cout << "Wrote " << finalCount << " shader(s) to " << outputPath.string() << "\n"; + Diagnostics::SetExitCode(0); + return 0; +}