diff --git a/.gitignore b/.gitignore index b38885de..1225f4a7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,12 +34,11 @@ *.app # Build artifacts -*build*/ -build-meta +/build/ # Python -/.venv* -/venv* +**/.venv* +**/venv* # ide folder .vscode/* @@ -52,12 +51,11 @@ build-meta docs Testing bin +install test/output -test/assets/fbx/*.usd -test/assets/gltf/*.usd -test/assets/obj/*/*.usd -test/assets/ply/*.usd -test/assets/stl/*.usd +test/assets/**/* +test/refs +test/test_output.txt # macOS system files .DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt index 53d3434d..7c38af56 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,11 @@ set(VERSION ${CPACK_PACKAGE_VERSION_MAJOR}.${CPACK_PACKAGE_VERSION_MINOR}.${CPAC message(STATUS "PROJECT VERSION IS: ${VERSION}") configure_file(version.h.in "${CMAKE_CURRENT_BINARY_DIR}/version.h") +# CMP0077 is used to set the default policy for the third party packages so that +# we can set the options without explicitly creating CACHE variables. +set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) + +get_directory_property(HAS_PARENT PARENT_DIRECTORY) if (HAS_PARENT) set(usd_fileformats_standalone_default FALSE) else () @@ -24,6 +29,9 @@ if(USD_FILEFORMATS_STANDALONE) list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) endif() include(cmake/compiler_config.cmake) +include(cmake/register_plugins.cmake) +include(GNUInstallDirs) +include(CMakeDependentOption) option(USD_FILEFORMATS_BUILD_TESTS "Build the unit tests" ON) option(USD_FILEFORMATS_ENABLE_FBX "Enables fbx plugin" ON) @@ -34,6 +42,13 @@ option(USD_FILEFORMATS_ENABLE_PLY "Enables ply plugin" ON) option(USD_FILEFORMATS_ENABLE_STL "Enables stl plugin" ON) option(USD_FILEFORMATS_ENABLE_SBSAR "Enables sbsar plugin" OFF) option(USD_FILEFORMATS_ENABLE_DRACO "Enables draco for the gltf plugin" OFF) + +option(USD_FILEFORMATS_FBX_SANDBOX "Enables fbx sandboxing" OFF) +option(USD_FILEFORMATS_GLTF_SANDBOX "Enables gltf sandboxing" OFF) +option(USD_FILEFORMATS_OBJ_SANDBOX "Enables obj sandboxing" OFF) +option(USD_FILEFORMATS_PLY_SANDBOX "Enables ply sandboxing" OFF) +option(USD_FILEFORMATS_SPZ_SANDBOX "Enables spz sandboxing" OFF) +option(USD_FILEFORMATS_STL_SANDBOX "Enables stl sandboxing" OFF) option(USD_FILEFORMATS_FETCH_GTEST "Forces FetchContent for GTest" ON) option(USD_FILEFORMATS_FETCH_TINYGLTF "Forces FetchContent for TinyGLTF" ON) option(USD_FILEFORMATS_FETCH_DRACO "Forces FetchContent for Draco" OFF) @@ -54,6 +69,10 @@ option(USD_FILEFORMATS_FETCH_SPHERICAL_HARMONICS "Forces FetchContent for Spheri # NOTE: These MaterialX shaders are not compatible with Reality Composer. option(USD_FILEFORMATS_ENABLE_MTLX "Enables MaterialX material representation" OFF) +option(USD_FILEFORMATS_ENABLE_INSTALL "Enable installation rules" ON) +cmake_dependent_option(USD_FILEFORMATS_ENABLE_INSTALL_PLUGINFO_ROOT "Enable installation of `plugInfo.root.json` files" ON "USD_FILEFORMATS_ENABLE_INSTALL" OFF) +option(PRODUCT_STRING "Define the product string which is appended to the generated usd comment" "") + # unary_function and binary_function are no longer provided in C++17 and newer Standard modes. # They can be re-enabled with _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION add_compile_definitions(_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION) @@ -64,6 +83,10 @@ if (USD_FILEFORMATS_BUILD_TESTS AND USD_FILEFORMATS_STANDALONE) enable_testing() endif () +if (USD_FILEFORMATS_BUILD_TESTS) + add_subdirectory(test/gtest_common) +endif () + if (USD_FILEFORMATS_ENABLE_ASM) message("Building with ASM") add_definitions(-DUSD_FILEFORMATS_ENABLE_ASM) @@ -98,46 +121,55 @@ if (USD_FILEFORMATS_STANDALONE) endif () endif () +if(APPLE) + set(plugin_install_rpath_root @loader_path) +else() + set(plugin_install_rpath_root $ORIGIN) +endif() + +if(PRODUCT_STRING) + add_definitions(-DPRODUCT_STRING="${PRODUCT_STRING}") +endif() + add_subdirectory(utils) -# Add a new file format to the build. This macro will add the relevant subdirectory and set the -# installation destination needed by that plugin's CMakeLists.txt so it installs into -# usd-fileformats-plugins/bin/plugin/usd -# -# New variables: -# - USD${FILEFORMAT}_DESTINATION: Where the fileformat libraries will be installed. This will -# typically be "plugin/usd" -# Example: USDFBX_DESTINATION -# -# @param SUBDIRECTORY_NAME The name of the subdirectory to add. This should be the same name as -# directory of the plugin, and will typically be all lowercase -macro(add_usd_fileformat SUBDIRECTORY_NAME) - string(TOUPPER ${SUBDIRECTORY_NAME} FILEFORMAT) - - set(USD${FILEFORMAT}_DESTINATION "plugin/usd") - add_subdirectory(${SUBDIRECTORY_NAME}) -endmacro() +# Generic, protocol-free libraries reused by the sandbox protocol layer (and, +# later, other out-of-process work). Built unconditionally; tiny and dependency-light. +add_subdirectory(serialization) + +add_subdirectory(ipc) + +include(cmake/AddUsdFileformat.cmake) + +# Initialize the list for all the plugins +set_property(GLOBAL PROPERTY USD_FILEFORMATS_ENABLED_PLUGINS "") +set_property(GLOBAL PROPERTY USD_FILEFORMATS_SANDBOXED_PLUGINS "") if (USD_FILEFORMATS_ENABLE_FBX) - add_usd_fileformat(fbx) + add_usd_fileformat(fbx USD_FILEFORMATS_FBX_SANDBOX) endif() if (USD_FILEFORMATS_ENABLE_GLTF) - add_usd_fileformat(gltf) + add_usd_fileformat(gltf USD_FILEFORMATS_GLTF_SANDBOX) endif() if (USD_FILEFORMATS_ENABLE_OBJ) - add_usd_fileformat(obj) + add_usd_fileformat(obj USD_FILEFORMATS_OBJ_SANDBOX) endif() if (USD_FILEFORMATS_ENABLE_PLY) - add_usd_fileformat(ply) + add_usd_fileformat(ply USD_FILEFORMATS_PLY_SANDBOX) endif() if (USD_FILEFORMATS_ENABLE_SBSAR) - add_usd_fileformat(sbsar) + # SBSAR does not support sandboxing + add_usd_fileformat(sbsar FALSE) endif() if (USD_FILEFORMATS_ENABLE_SPZ) - add_usd_fileformat(spz) + add_usd_fileformat(spz USD_FILEFORMATS_SPZ_SANDBOX) endif() if (USD_FILEFORMATS_ENABLE_STL) - add_usd_fileformat(stl) + add_usd_fileformat(stl USD_FILEFORMATS_STL_SANDBOX) +endif() + +if (USD_FILEFORMATS_SANDBOXED_EXTENSIONS) # Only build sandbox if there are sandboxed extensions + add_subdirectory(sandbox) endif() if (UNIX AND NOT APPLE) diff --git a/README.md b/README.md index ff783055..36e0dc0a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The following dependencies are needed: | [Happly](https://github.com/nmwsharp/happly.git) | cfa2611 | usdply | no | | [Spherical Harmonics](https://github.com/google/spherical-harmonics) | ccb6c7f | usdply, usdspz | no | | [Spz](https://github.com/nianticlabs/spz) | fd4e2a5 | usdspz | no | -| [Substance](https://developer.adobe.com/substance3d-sdk/) | 9.1.2 | usdsbsar | no | +| [Substance](https://developer.adobe.com/substance3d-sdk/) | 9.4.1 | usdsbsar | no | ## Coding Standards @@ -98,7 +98,7 @@ If USD was built with Python (default behavior with the build script), ensure th * Substance SDK Integration 1. Download the SDK: Visit the [Adobe Developer Console](https://developer.adobe.com/console/servicesandapis#) and log in or create an account if necessary. - 2. Locate the SDK: Use the search bar to find the ‘Adobe Substance 3D Materials SDK’. Version 9.1.2 + 2. Locate the SDK: Use the search bar to find the ‘Adobe Substance 3D Materials SDK’. Version 9.4.1 ### 2. Get it ``` @@ -127,6 +127,7 @@ where: | -DGTest_ROOT | Points to the GTest installation | empty | all tests | | -DFBXSDK_ROOT | Points to the Fbx installation | empty | usdfbx | | -Dsubstance_DIR | Points to the Substance SDK installation | empty | usdsbsar | +| -DCMAKE_OSX_ARCHITECTURES | Target arch on macOS; set to `arm64` so an arm64-only Substance SDK links (selects `neon_blend`, not the universal `cpu_blend`) | empty | usdsbsar (macOS) | | -DZLIB_ROOT | Points to the ZLIB installation | empty | usdfbx | | -DLibXml2_ROOT | Points to the LibXml2 installation | empty | usdfbx | | -DTinyGLTF_ROOT | Points to the TinyGLTF installation | empty | usdgltf | @@ -270,6 +271,44 @@ stage.Export("cube.usd") Refer to each plugin's README for more details. +## Material networks + +When converting to USD, the plugins can write up to three material network representations per material: **UsdPreviewSurface**, **OpenPBR** (authored as a MaterialX network), and **Adobe Standard Material (ASM)**. OpenPBR is now written by default alongside UsdPreviewSurface. + +### Current defaults + +| Representation | Flag | Default | Status | +|---|---|---|---| +| UsdPreviewSurface | `USD_FILEFORMATS_WRITE_USDPREVIEWSURFACE` | on | Deprecated, still supported | +| OpenPBR | `USD_FILEFORMATS_WRITE_OPENPBR` | on | Default | +| Adobe Standard Material (ASM) | `USD_FILEFORMATS_WRITE_ASM` | off | Deprecated, still supported | +| Native OpenPBR processing | `USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING` | on | | + +ASM and UsdPreviewSurface are **deprecated** and will be removed in a future release, but they remain fully supported for now. Enabling either emits a one-time deprecation warning. + +### Enabling and disabling + +Each representation can be toggled three ways, listed highest priority first: + +1. **Per file**, via `SDF_FORMAT_ARGS` on the asset path (affects a single open). Arguments are `writeUsdPreviewSurface`, `writeASM`, and `writeOpenPBR`, each `true` or `false`: + ``` + usdcat "cube.fbx:SDF_FORMAT_ARGS:writeOpenPBR=false&writeUsdPreviewSurface=true" -o cube.usd + ``` +2. **Per process**, via environment variables (values `0` or `1`): + ``` + export USD_FILEFORMATS_WRITE_OPENPBR=0 + ``` + Available variables: `USD_FILEFORMATS_WRITE_USDPREVIEWSURFACE`, `USD_FILEFORMATS_WRITE_ASM`, `USD_FILEFORMATS_WRITE_OPENPBR`, `USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING`. +3. **At build time**, via the compile-time defaults: + ``` + -DUSD_FILEFORMATS_DEFAULT_WRITE_OPENPBR=OFF + ``` + (`-DUSD_FILEFORMATS_ENABLE_ASM=ON` is a convenience option that turns the ASM default on.) + +### Known issue: OpenPBR requires a recent USD + +OpenPBR is authored as a **MaterialX** network. These networks are not supported by older versions of USD: **USD 26.03 and below** will fail to render OpenPBR materials (for example, shader compilation errors). When targeting an older USD, disable OpenPBR and rely on UsdPreviewSurface using any mechanism above, for example `USD_FILEFORMATS_WRITE_OPENPBR=0`. + ## Documentation To generate the documentation go to the project root folder and enter: diff --git a/changelog.txt b/changelog.txt index 5ee057f4..34aa870b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,10 +1,90 @@ -v2026.5 May 22nd, 2026 +v2026.07 July 17th, 2026 + +Features + all + - Flip writer defaults to native OpenPBR + - Deprecate ASM and UsdPreviewSurface writers in favor of native OpenPBR + - Replace the default ASM material struct with OpenPBR using a feature flag + fbx + - Import OpenPBR materials from Maya and 3ds Max exports + - add support for new SDF_FORMAT_ARGS:importLights option + gltf + - Add various OpenPBR features to glTF import + - add support for new SDF_FORMAT_ARGS:importLights option + sandbox + - added sandbox module alongside ipc and serialization modules + sbsar + - Create shader graph to convert sbsar tangent input to geometry_tangent + - Add API to get resolved path from image cache for SBSAR SAL interop + utils + - UTF-8-aware MakeValidUsdIdentifier prim-name sanitizer + - Per-node custom properties as prim customData + +Fixes + all + - Move normalScale from texture reader scale/bias to ND_normalmap scale input + - Use constants to initialize normal scale/bias + fbx + - Preserve material assignment for InstanceProxy models + - Preserve rotation on FBX nodes with near-zero scale + - Resolve sibling textures when GetRelativeFileName returns absolute-looking path + - Work around FBX SDK 2020.3.9 triangulate crash on skinned meshes + - Preserve per-GeomSubset material bindings on export + - Import Phong materials as dielectric by default + - Matte Lambert roughness and reflectivity-gated Phong metalness + - Preserve roughness when converting Phong FBX materials to USD + - Zero specular_weight and transfer Lambert Diffuse weight to base_weight + - Attach materials to every shared-mesh instance node + - Triangulate meshes with untriangulated n-gon polygons + gltf + - Do proper conversion of gltf anisotropy to OpenPBR on import + - Fix for crash caused by failing to check value validity + - Skip UsdLuxDomeLight on export instead of converting to point light + - Reject animation samplers with mismatched accessor counts + - Avoid duplicate tinygltf symbols with Xcode 26 linker + - Author mesh extent on import to keep bbox queries O(1) + - Validate vertex attribute accessor types to prevent heap overflow + - Fold OpenPBR base_weight into baseColorFactor on export + obj + - Fix missing materials with combineGroups and separateGroupsAsSubsets + - Parse ZBrush #MRGB vertex colors in OBJ import + - Defer parser warnings from TBB workers to main thread + - Removing stale obj test + - Derive roughness from Phong shininess on import + - Keep map_Kd texture when Kd is a placeholder zero + - Escape untrusted bytes in OBJ parser diagnostics + ply + - One-liner fix to narrow opacity filtering with std::isnan + - Write per-face color/opacity at submesh offset and guard property size + sbsar + - Various improvements to the SBSAR render thread + - Incorrect mapping of asm 'scatteringColor' and added missing mapping of 'scatteringDistance' + - Fix intermittent null-deref crash in render thread on startup + - Fix mapping of sbsar outputs using OpenPBR material model to openpbr material inputs + - Default-initialize RenderResultCache members and guard against nullptr + - Guard against null mRenderResultImage in _OpenForReading + - Join render thread before static teardown + - Suppress error for empty path in SBSAR image input + - Fix reading of sbsar images + stl + - Reject non-3D-model files sharing the .stl extension + utils + - Uniquify meshes, curves, and child nodes in shared prim namespace + - Post a coding error when authoring duplicate prim children + - Uniquify node names (especially synthesized Materials node) + - Guard Image::allocate/read against integer overflow + +Docs + fbx + - Add FBX SDK update process documentation + +v2026.05 May 22nd, 2026 Fixes gltf - add input validation to NGP extension to prevent memory corruption vulnerabilities -v2026.3 March 6th, 2026 +v2026.03 March 6th, 2026 General Changes: - Fixed compatibility with USD 25.x diff --git a/cmake/AddUsdFileformat.cmake b/cmake/AddUsdFileformat.cmake new file mode 100644 index 00000000..de6d49ce --- /dev/null +++ b/cmake/AddUsdFileformat.cmake @@ -0,0 +1,44 @@ +# Empty list that will be populated with the sandboxed file formats +set(USD_FILEFORMATS_SANDBOXED_EXTENSIONS) + +# Add a new file format to the build. This macro will add the relevant subdirectory and determine +# if the format should be sandboxed. If it is, it will add the extensions to the +# USD_FILEFORMATS_SANDBOXED_EXTENSIONS list and create relevant variables. +# +# New variables: +# - USD${FILEFORMAT}_DESTINATION: Where the fileformat libraries will be installed. This will be +# either bin/plugin/usd (for regular fileformats) or +# bin/plugin_sandboxed/usd (for sandboxed fileformats). +# Example: USDFBX_DESTINATION +# +# This function also requires the fileformat CMakeLists.txt to set ${FILEFORMAT}_EXT_LIST to a +# list of extensions that the format supports. These must be case sensitive, so the sandbox proxy +# resolver can find all variants of a file extension. +# +# @param SUBDIRECTORY_NAME The name of the subdirectory to add. +# @param SANDBOXED True if the fileformat should be sandboxed, false otherwise. +macro(add_usd_fileformat SUBDIRECTORY_NAME SANDBOXED) + # Ensure the format name is uppercase for use in variables + string(TOUPPER ${SUBDIRECTORY_NAME} FILEFORMAT) + if(NOT DEFINED USD${FILEFORMAT}_DESTINATION) + if (${SANDBOXED}) + set(USD${FILEFORMAT}_DESTINATION "plugin_sandboxed/usd") + # Set before add_subdirectory so the child scope can read it + set(_FILEFORMAT_SANDBOXED TRUE) + else() + set(USD${FILEFORMAT}_DESTINATION "plugin/usd") + set(_FILEFORMAT_SANDBOXED FALSE) + endif() + endif() + + add_subdirectory(${SUBDIRECTORY_NAME}) + + # The fileformat CMakeLists.txt should have set ${FILEFORMAT}_EXT_LIST, so we can save it + if (${SANDBOXED}) + if (NOT DEFINED ${FILEFORMAT}_EXT_LIST) + message(FATAL_ERROR "The fileformat ${FILEFORMAT} does not define the " + "${FILEFORMAT}_EXT_LIST variable required for sandboxing.") + endif() + list(APPEND USD_FILEFORMATS_SANDBOXED_EXTENSIONS ${${FILEFORMAT}_EXT_LIST}) + endif() +endmacro() diff --git a/cmake/FindSphericalHarmonics.cmake b/cmake/FindSphericalHarmonics.cmake index 04ced4ff..109b9343 100644 --- a/cmake/FindSphericalHarmonics.cmake +++ b/cmake/FindSphericalHarmonics.cmake @@ -59,7 +59,11 @@ if(USD_FILEFORMATS_FORCE_FETCHCONTENT OR USD_FILEFORMATS_FETCH_SPHERICAL_HARMONI target_include_directories(SphericalHarmonics PUBLIC ${SH_INCLUDE_DIR}) target_link_libraries(SphericalHarmonics PUBLIC Eigen3::Eigen) set_property(TARGET SphericalHarmonics PROPERTY POSITION_INDEPENDENT_CODE ON) - set_property(TARGET SphericalHarmonics PROPERTY CXX_STANDARD 17) + if (CMAKE_CXX_STANDARD) + set_property(TARGET SphericalHarmonics PROPERTY CXX_STANDARD ${CMAKE_CXX_STANDARD}) + else() + set_property(TARGET SphericalHarmonics PROPERTY CXX_STANDARD 17) + endif() target_compile_definitions(SphericalHarmonics PRIVATE "_USE_MATH_DEFINES") add_library(SphericalHarmonics::SphericalHarmonics ALIAS SphericalHarmonics) else() @@ -85,7 +89,11 @@ else() target_include_directories(SphericalHarmonics PUBLIC ${SH_INCLUDE_DIR}) target_link_libraries(SphericalHarmonics PUBLIC Eigen3::Eigen) set_property(TARGET SphericalHarmonics PROPERTY POSITION_INDEPENDENT_CODE ON) - set_property(TARGET SphericalHarmonics PROPERTY CXX_STANDARD 17) + if (CMAKE_CXX_STANDARD) + set_property(TARGET SphericalHarmonics PROPERTY CXX_STANDARD ${CMAKE_CXX_STANDARD}) + else() + set_property(TARGET SphericalHarmonics PROPERTY CXX_STANDARD 17) + endif() target_compile_definitions(SphericalHarmonics PRIVATE "_USE_MATH_DEFINES") add_library(SphericalHarmonics::SphericalHarmonics ALIAS SphericalHarmonics) diff --git a/cmake/Findspz.cmake b/cmake/Findspz.cmake index 84290d8b..e5735d9c 100644 --- a/cmake/Findspz.cmake +++ b/cmake/Findspz.cmake @@ -38,15 +38,27 @@ if(NOT TARGET ZLIB::ZLIB) find_package(ZLIB REQUIRED) endif() +set(SPZ_BUILD_TOOLS OFF) +set(SPZ_BUILD_EXTENSIONS ON) + if(USD_FILEFORMATS_FORCE_FETCHCONTENT OR USD_FILEFORMATS_FETCH_SPZ) message(STATUS "Fetching spz") include(CPM) - CPMAddPackage( - NAME spz - GIT_REPOSITORY "https://github.com/nianticlabs/spz.git" - GIT_TAG "v1.1.0+adobe.4" - OPTIONS "BUILD_SHARED_LIBS OFF" - ) + # zstd's installed CMake config breaks incremental re-configure: find_package(zstd) + # re-discovers it in the install prefix and its import guard collides with the zstd + # target already created this pass. Force spz's FetchContent path instead. The fetch + # runs in a function so CMAKE_DISABLE_FIND_PACKAGE_zstd stays local to it (the spz + # target it creates is global and persists); later find_package(zstd) is unaffected. + function(_spz_fetch_with_bundled_zstd) + set(CMAKE_DISABLE_FIND_PACKAGE_zstd TRUE) + CPMAddPackage( + NAME spz + GIT_REPOSITORY "https://github.com/nianticlabs/spz.git" + GIT_TAG "21715c3b7a609ea6fb7c69b8ae42181a12b59f22" # v3.0.0+adobe.32 + OPTIONS "BUILD_SHARED_LIBS OFF" + ) + endfunction() + _spz_fetch_with_bundled_zstd() set(spz_FOUND TRUE) if (NOT MSVC) target_compile_options(spz PRIVATE "-Wno-shorten-64-to-32") diff --git a/cmake/compiler_config.cmake b/cmake/compiler_config.cmake index 760c17a5..fb304741 100644 --- a/cmake/compiler_config.cmake +++ b/cmake/compiler_config.cmake @@ -1,14 +1,19 @@ function(usd_plugin_compile_config TARGET) set(CMAKE_CXX_EXTENSIONS OFF) - target_compile_features(${TARGET} PUBLIC cxx_std_17) + if (CMAKE_CXX_STANDARD EQUAL 20) + target_compile_features(${TARGET} PUBLIC cxx_std_20) + else() + target_compile_features(${TARGET} PUBLIC cxx_std_17) + endif() if (CMAKE_CXX_COMPILER_ID MATCHES "MSVC") target_compile_options(${TARGET} PRIVATE + /utf-8 # treat source and execution character sets as UTF-8 /W3 # we want to be as strict as possible $<$:/WX> # enable two-phase name lookup and other strict checks (binding a non-const reference to a temporary, etc..) - $<$>:/permissive-> + $<$>:/permissive-> /Zi # enable pdb generation. /Zc:rvalueCast # standards compliant. # https://developercommunity.visualstudio.com/content/problem/914943/zcinline-removes-extern-symbols-inside-anonymous-n.html @@ -25,6 +30,8 @@ function(usd_plugin_compile_config TARGET) /wd4996 /wd4180 /wd4251 # exporting STL classes + /Zc:__cplusplus # Make sure substance engine zero initilization works + /GS # stack buffer security checks (assert default-on, prevent silent regression) ) target_compile_definitions(${TARGET} PRIVATE NOMINMAX @@ -49,6 +56,10 @@ function(usd_plugin_compile_config TARGET) -Wno-unused-local-typedefs -m64 -Wrange-loop-analysis + -fstack-protector-strong + # _FORTIFY_SOURCE=2 is a no-op without optimization and warns at -O0; gate to + # Release/RelWithDebInfo where -O2 or higher is guaranteed. + $<$:-D_FORTIFY_SOURCE=2> ) target_compile_definitions(${TARGET} PRIVATE NOMINMAX @@ -67,6 +78,10 @@ function(usd_plugin_compile_config TARGET) -Wno-deprecated-declarations -Wno-unused-local-typedefs -m64 + -fstack-protector-strong + # _FORTIFY_SOURCE=2 is a no-op without optimization and warns at -O0; gate to + # Release/RelWithDebInfo where -O2 or higher is guaranteed. + $<$:-D_FORTIFY_SOURCE=2> ) target_compile_definitions(${TARGET} PRIVATE NOMINMAX @@ -77,4 +92,44 @@ function(usd_plugin_compile_config TARGET) ) endif() -endfunction() \ No newline at end of file + # Apply link-side hardening (RELRO, /DYNAMICBASE) via the companion helper. + # Defined below in this file; callable here because both functions are fully + # registered by the time any CMakeLists.txt calls usd_plugin_compile_config. + usd_plugin_link_config(${TARGET}) + +endfunction() + +# usd_plugin_link_config(TARGET) +# +# Apply link-side binary-hardening flags to TARGET (plugin shared library or executable). +# This is invoked by usd_plugin_compile_config; call it directly only for targets +# that don't use usd_plugin_compile_config. +# Platform behaviour: +# Linux : Full RELRO (-z relro -z now) + /DYNAMICBASE equivalent is PIE handled +# separately per target. SandboxedProcess also needs -pie at link, but +# that is set via POSITION_INDEPENDENT_CODE on the target, not here. +# macOS : Mach-O has no RELRO segment; no extra link flags needed. +# Windows: Assert /DYNAMICBASE and /HIGHENTROPYVA (default-on, but explicit to +# prevent silent regression from linker subsystem changes). +function(usd_plugin_link_config TARGET) + if (CMAKE_CXX_COMPILER_ID MATCHES "MSVC") + # /DYNAMICBASE: enable ASLR (address-space layout randomisation). + # /HIGHENTROPYVA: use the full 64-bit address space for ASLR entropy. + # Both are default-on for x64 MSVC but asserted here to prevent regression. + target_link_options(${TARGET} PRIVATE + /DYNAMICBASE + /HIGHENTROPYVA + ) + elseif(UNIX AND NOT APPLE) + # Full RELRO: makes the GOT read-only after dynamic linking completes, + # preventing attacker overwrites of function pointers. + # -z relro alone (partial RELRO) leaves the GOT writable during execution; + # -z now (BIND_NOW) resolves all symbols at load time so the full GOT can + # be locked. The combined cost is a slight increase in startup latency, + # acceptable for a file-conversion process that is not latency-critical. + target_link_options(${TARGET} PRIVATE + -Wl,-z,relro + -Wl,-z,now + ) + endif() +endfunction() diff --git a/cmake/register_plugins.cmake b/cmake/register_plugins.cmake new file mode 100644 index 00000000..f6faca42 --- /dev/null +++ b/cmake/register_plugins.cmake @@ -0,0 +1,15 @@ +include_guard(GLOBAL) + +function(fileformats_register_plugin plugin) + get_property(_enabled_plugins GLOBAL PROPERTY USD_FILEFORMATS_ENABLED_PLUGINS) + + list(APPEND _enabled_plugins ${plugin}) + + list(REMOVE_DUPLICATES _enabled_plugins) + + set_property(GLOBAL PROPERTY USD_FILEFORMATS_ENABLED_PLUGINS ${_enabled_plugins}) + + if(_FILEFORMAT_SANDBOXED) + set_property(GLOBAL APPEND PROPERTY USD_FILEFORMATS_SANDBOXED_PLUGINS ${plugin}) + endif() +endfunction() diff --git a/cmake/substance_engine.cmake b/cmake/substance_engine.cmake index 000b9b14..fab481e8 100644 --- a/cmake/substance_engine.cmake +++ b/cmake/substance_engine.cmake @@ -2,24 +2,37 @@ set(SUBSTANCE_TARGETS) list(APPEND SUBSTANCE_TARGETS Substance::Framework) list(APPEND SUBSTANCE_TARGETS Substance::Linker) +# Find the package first so we can check which blend libraries are actually +# present in the SDK (e.g. universal packages ship cpu_blend instead of +# neon_blend/sse2_blend). +if(NOT TARGET Substance::Framework) + find_package(substance CONFIG REQUIRED) +endif() + if(WIN32) list(APPEND SUBSTANCE_TARGETS Substance::sse2_blend) elseif(LINUX) list(APPEND SUBSTANCE_TARGETS Substance::sse2_blend) elseif(APPLE) if(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") - list(APPEND SUBSTANCE_TARGETS Substance::sse2_blend) + if(NOT sse2_blend_REL MATCHES "NOTFOUND") + list(APPEND SUBSTANCE_TARGETS Substance::sse2_blend) + else() + list(APPEND SUBSTANCE_TARGETS Substance::cpu_blend) + endif() elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") - list(APPEND SUBSTANCE_TARGETS Substance::neon_blend) + # Universal SDK packages ship cpu_blend rather than neon_blend. + if(NOT neon_blend_REL MATCHES "NOTFOUND") + list(APPEND SUBSTANCE_TARGETS Substance::neon_blend) + else() + list(APPEND SUBSTANCE_TARGETS Substance::cpu_blend) + endif() + else() + # Universal (multi-arch) build or unspecified architecture. + list(APPEND SUBSTANCE_TARGETS Substance::cpu_blend) endif() endif() -# If the SDK target has already been declared, do not attempt to locate -# it again -if(NOT TARGET Substance::Framework) - find_package(substance CONFIG REQUIRED) -endif() - if(USDSBSAR_ENABLE_INSTALL) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") install( @@ -56,8 +69,23 @@ if(USDSBSAR_ENABLE_INSTALL) COMPONENT Runtime ) elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + if(NOT neon_blend_REL MATCHES "NOTFOUND") + install( + FILES $ + DESTINATION lib + COMPONENT Runtime + ) + else() + install( + FILES $ + DESTINATION lib + COMPONENT Runtime + ) + endif() + else() + # Universal build install( - FILES $ + FILES $ DESTINATION lib COMPONENT Runtime ) diff --git a/fbx/CMakeLists.txt b/fbx/CMakeLists.txt index ee74fa74..355ffbec 100644 --- a/fbx/CMakeLists.txt +++ b/fbx/CMakeLists.txt @@ -1,5 +1,6 @@ option(USD_FILEFORMATS_ENABLE_ASSET_TESTS "Build the more in depth unit tests using downloaded assets." OFF) -option(USDFBX_ENABLE_INSTALL "Enable installation of plugin artifacts" ON) +cmake_dependent_option(USDFBX_ENABLE_INSTALL "Enable installation of plugin artifacts" ON "USD_FILEFORMATS_ENABLE_INSTALL" OFF) + if (NOT TARGET usd) @@ -14,6 +15,10 @@ endif() add_subdirectory(src) + +# Pass this list from the src/CMakeLists.txt to the parent scope +set(FBX_EXT_LIST ${FBX_EXT_LIST} PARENT_SCOPE) + if(USD_FILEFORMATS_BUILD_TESTS) add_subdirectory(tests) endif() @@ -23,3 +28,4 @@ set(CPACK_INSTALL_CMAKE_PROJECTS "src;usdFbx;ALL;/") include(CPack) +fileformats_register_plugin("usdFbx") diff --git a/fbx/README.md b/fbx/README.md index 1f9f33e4..9a40370e 100644 --- a/fbx/README.md +++ b/fbx/README.md @@ -101,17 +101,21 @@ Note that PBR materials are not supported on export, only Phong * `fbxAssetsPath`: Deprecated in favor of `assetsPath`. -* `writeUsdPreviewSurface`: Generate a UsdPreviewSurface based network for each material. Default is `true` +* `importLights`: Controls whether to import lights or not. Default is `true` + + When this is disabled, fbx lights will be ignored and not converted to USD on import. + +* `writeUsdPreviewSurface`: Generate a UsdPreviewSurface based network for each material. Default is `true` (deprecated) UsdPreviewSurface and its associated nodes are a universally understood USD material description and all application should support them. The PBR capabilities are limited. -* `writeASM`: Generate a ASM (Adobe Standard Material) based network for each material. Default is `true` +* `writeASM`: Generate a ASM (Adobe Standard Material) based network for each material. Default is `false` (deprecated) ASM is a standard supported by many Adobe applications with richer support for PBR capabilities. It will be superseded by OpenPBR in the near future. -* `writeOpenPBR`: Generate a OpenPBR based material network for each material. Default is `false` +* `writeOpenPBR`: Generate a OpenPBR based material network for each material. Default is `true` OpenPBR is a new industry standard that will have wide spread support, but is still in its infancy. The material network uses `MaterialX` nodes to express individual operations and has an `OpenPBR` surface, diff --git a/fbx/src/CMakeLists.txt b/fbx/src/CMakeLists.txt index e35c23b8..5cbbc6c2 100644 --- a/fbx/src/CMakeLists.txt +++ b/fbx/src/CMakeLists.txt @@ -1,5 +1,7 @@ add_library(usdFbx SHARED) +set(FBX_EXT_LIST "fbx;Fbx;FBX" PARENT_SCOPE) + usd_plugin_compile_config(usdFbx) target_compile_definitions(usdFbx PRIVATE USDFBX_EXPORTS) @@ -45,29 +47,39 @@ PRIVATE # Allow an option for deferring the path replacement to install time if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) - set(PLUG_INFO_LIBRARY_PATH "\$\{PLUG_INFO_LIBRARY_PATH\}") + # We still need to go through `configure_file` even with `USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT` because we burn additional CMake variable beyond PLUG_INFO_LIBRARY_PATH + # So we set `PLUG_INFO_LIBRARY_PATH` as a no op value and let other CMake variables being burnt in + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") else() set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}usdFbx${CMAKE_SHARED_LIBRARY_SUFFIX}") endif() -configure_file(plugInfo.json.in plugInfo.json) -set_target_properties(usdFbx PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) -set_target_properties(usdFbx PROPERTIES RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") +configure_file(plugInfo.json.in plugInfo.json) +set_property(TARGET usdFbx APPEND PROPERTY RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json") +set_property(TARGET usdFbx APPEND PROPERTY RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") # USDFBX_DESTINATION is set in the parent scope by the add_usd_fileformat macro if(USDFBX_ENABLE_INSTALL) + set_property(TARGET usdFbx + APPEND PROPERTY + INSTALL_RPATH "${plugin_install_rpath_root}/." + ) install( TARGETS usdFbx + EXPORT usd-fileformats-targets RUNTIME DESTINATION ${USDFBX_DESTINATION} COMPONENT Runtime LIBRARY DESTINATION ${USDFBX_DESTINATION} COMPONENT Runtime + ARCHIVE DESTINATION ${USDFBX_DESTINATION} COMPONENT Runtime RESOURCE DESTINATION ${USDFBX_DESTINATION}/usdFbx/resources COMPONENT Runtime ) - install( - FILES plugInfo.root.json - DESTINATION ${USDFBX_DESTINATION} - RENAME plugInfo.json - COMPONENT Runtime - ) + if(USD_FILEFORMATS_ENABLE_INSTALL_PLUGINFO_ROOT) + install( + FILES plugInfo.root.json + DESTINATION ${USDFBX_DESTINATION} + RENAME plugInfo.json + COMPONENT Runtime + ) + endif() endif() diff --git a/fbx/src/fbxExport.cpp b/fbx/src/fbxExport.cpp index 66b32307..158c5c80 100644 --- a/fbx/src/fbxExport.cpp +++ b/fbx/src/fbxExport.cpp @@ -11,8 +11,10 @@ governing permissions and limitations under the License. */ #include "fbxExport.h" #include "debugCodes.h" +#include #include #include +#include #include #include #include @@ -42,6 +44,14 @@ struct ExportFbxContext Fbx* fbx = nullptr; std::vector materials; std::vector meshes; + // Per-mesh ordered list of USD material indices to attach to the FbxNode that + // carries the mesh. The order matches the per-polygon material slot indices + // emitted by exportFbxMeshes, so bindMaterial can call AddMaterial in the + // same order. + std::vector> meshNodeMaterialOrder; + // Per-mesh, per-polygon material slot index into meshNodeMaterialOrder. + // Empty when the mesh has no subsets and falls back to eAllSame. + std::vector> meshPolyMatIdx; std::vector cameras; std::vector lights; std::vector skeletons; @@ -238,9 +248,8 @@ exportFbxAnimationTracks(ExportFbxContext& ctx) { if (ctx.usd->hasAnimations) { ctx.animStackData.resize(ctx.usd->animationTracks.size()); - for (int animationTrackIndex = 0; animationTrackIndex < ctx.usd->animationTracks.size(); + for (size_t animationTrackIndex = 0; animationTrackIndex < ctx.usd->animationTracks.size(); animationTrackIndex++) { - const AnimationTrack& track = ctx.usd->animationTracks[animationTrackIndex]; ExportFbxAnimStackData& exportAnimStackData = ctx.animStackData[animationTrackIndex]; // Create anim stack @@ -321,7 +330,7 @@ exportFbxTransform(ExportFbxContext& ctx, const Node& node, FbxNode* fbxNode) FbxDouble3(node.translation[0], node.translation[1], node.translation[2])); } - for (int animationTrackIndex = 0; animationTrackIndex < node.animations.size(); + for (size_t animationTrackIndex = 0; animationTrackIndex < node.animations.size(); animationTrackIndex++) { const NodeAnimation& nodeAnimation = node.animations[animationTrackIndex]; ExportFbxAnimStackData& exportAnimStackData = ctx.animStackData[animationTrackIndex]; @@ -403,7 +412,7 @@ exportFbxTransform(ExportFbxContext& ctx, const Node& node, FbxNode* fbxNode) fbxNode->LclRotation.Set(FbxDouble3(euler[0], euler[1], euler[2])); } - for (int animationTrackIndex = 0; animationTrackIndex < node.animations.size(); + for (size_t animationTrackIndex = 0; animationTrackIndex < node.animations.size(); animationTrackIndex++) { const NodeAnimation& nodeAnimation = node.animations[animationTrackIndex]; ExportFbxAnimStackData& exportAnimStackData = ctx.animStackData[animationTrackIndex]; @@ -462,7 +471,7 @@ exportFbxTransform(ExportFbxContext& ctx, const Node& node, FbxNode* fbxNode) fbxNode->LclScaling.Set(FbxDouble3(node.scale[0], node.scale[1], node.scale[2])); } - for (int animationTrackIndex = 0; animationTrackIndex < node.animations.size(); + for (size_t animationTrackIndex = 0; animationTrackIndex < node.animations.size(); animationTrackIndex++) { const NodeAnimation& nodeAnimation = node.animations[animationTrackIndex]; ExportFbxAnimStackData& exportAnimStackData = ctx.animStackData[animationTrackIndex]; @@ -545,24 +554,189 @@ setElementUVs(FbxMesh* fbxMesh, FbxGeometryElementUV* elementUvs, const Primvar< } } +// Walks mesh.subsets and mesh.material to produce: +// - nodeMaterialOrder: deduplicated USD material indices, in the order they +// will be attached to the FbxNode (and the order eIndexToDirect points at). +// - polyToNodeMatIdx: per-polygon index into nodeMaterialOrder, length +// mesh.faces.size(). USD GeomSubset families come in three flavours +// (partition, nonOverlapping, unrestricted) and the Subset struct doesn't +// carry the family type, so overlaps are treated as last-write-wins. +// +// Returns false when there is nothing to bind (no subsets and mesh.material < 0). +static bool +buildSubsetMaterialMapping(const Mesh& mesh, + size_t materialCount, + std::vector& nodeMaterialOrder, + std::vector& polyToNodeMatIdx) +{ + nodeMaterialOrder.clear(); + polyToNodeMatIdx.clear(); + + if (mesh.subsets.empty()) { + return mesh.material >= 0 && static_cast(mesh.material) < materialCount; + } + + const size_t polyCount = mesh.faces.size(); + auto appendUnique = [&](int usdMatIdx) -> int { + if (usdMatIdx < 0 || static_cast(usdMatIdx) >= materialCount) { + return -1; + } + for (size_t k = 0; k < nodeMaterialOrder.size(); ++k) { + if (nodeMaterialOrder[k] == usdMatIdx) { + return static_cast(k); + } + } + nodeMaterialOrder.push_back(usdMatIdx); + return static_cast(nodeMaterialOrder.size() - 1); + }; + + // Start with every polygon unmapped; subsets fill in their covered faces, + // and any remaining -1 entries get a mesh-level fallback later. + polyToNodeMatIdx.assign(polyCount, -1); + + for (const Subset& subset : mesh.subsets) { + // Filter out-of-range face indices first so we don't reserve a node + // material slot for a subset that turns out to cover zero valid faces. + // Accumulate the offenders and emit a single warning per subset. + std::vector validFaces; + validFaces.reserve(subset.faces.size()); + size_t outOfRangeCount = 0; + int firstOutOfRange = 0; + for (int faceIdx : subset.faces) { + if (faceIdx < 0 || static_cast(faceIdx) >= polyCount) { + if (outOfRangeCount == 0) { + firstOutOfRange = faceIdx; + } + ++outOfRangeCount; + continue; + } + validFaces.push_back(faceIdx); + } + if (outOfRangeCount > 0) { + TF_WARN("FBX export: mesh '%s' subset has %zu out-of-range face " + "index/indices (first: %d, valid range [0, %zu)); ignoring.", + mesh.name.c_str(), + outOfRangeCount, + firstOutOfRange, + polyCount); + } + if (validFaces.empty()) { + continue; + } + const int slot = appendUnique(subset.material); + if (slot < 0) { + // Material invalid; leave these faces as orphans for fallback. + continue; + } + for (int faceIdx : validFaces) { + polyToNodeMatIdx[faceIdx] = slot; + } + } + + // Reserve a mesh-level fallback slot only if any polygon remains uncovered. + // This avoids attaching an unused mesh.material as a ghost subset when the + // subsets already partition every face. + bool hasOrphans = false; + for (int idx : polyToNodeMatIdx) { + if (idx < 0) { + hasOrphans = true; + break; + } + } + + if (hasOrphans) { + const int residualSlot = appendUnique(mesh.material); + if (residualSlot >= 0) { + for (int& idx : polyToNodeMatIdx) { + if (idx < 0) { + idx = residualSlot; + } + } + } else if (!nodeMaterialOrder.empty()) { + // No valid mesh.material; rebind orphans to slot 0 so FBX + // eIndexToDirect can address them (it has no "no material" code). + TF_WARN("FBX export: mesh '%s' has polygons not covered by any subset " + "and no mesh-level material binding; defaulting them to the " + "first subset material.", + mesh.name.c_str()); + for (int& idx : polyToNodeMatIdx) { + if (idx < 0) { + idx = 0; + } + } + } else { + // No usable bindings at all - nothing to emit. + nodeMaterialOrder.clear(); + polyToNodeMatIdx.clear(); + return false; + } + } + + return !nodeMaterialOrder.empty(); +} + void -createMeshMaterial(ExportFbxContext& ctx, const Mesh& mesh, FbxMesh* fbxMesh) +createMeshMaterial(ExportFbxContext& ctx, size_t meshIndex, FbxMesh* fbxMesh) { - if (mesh.material >= 0) { - FbxGeometryElementMaterial* elementMaterial = fbxMesh->CreateElementMaterial(); + const Mesh& mesh = ctx.usd->meshes[meshIndex]; + std::vector& nodeMaterialOrder = ctx.meshNodeMaterialOrder[meshIndex]; + std::vector& polyMatIdx = ctx.meshPolyMatIdx[meshIndex]; + polyMatIdx.clear(); + + if (!buildSubsetMaterialMapping(mesh, ctx.materials.size(), nodeMaterialOrder, polyMatIdx)) { + return; + } + + FbxGeometryElementMaterial* elementMaterial = fbxMesh->CreateElementMaterial(); + + // When only a single material ends up attached to the node (no subsets, or + // every covered polygon resolved to the same slot), eAllSame is sufficient + // and avoids writing a per-polygon index array. + if (nodeMaterialOrder.size() <= 1) { elementMaterial->SetMappingMode(FbxGeometryElement::eAllSame); elementMaterial->SetReferenceMode(FbxGeometryElement::eDirect); + polyMatIdx.clear(); + return; } + + // The actual per-polygon material indices are populated by FbxMesh's + // BeginPolygon(materialIndex) calls during polygon emission below; the FBX + // SDK wires that argument into this element's IndexArray internally. + // Manipulating the IndexArray directly is silently ignored by the + // serializer, so we leave it alone. + elementMaterial->SetMappingMode(FbxGeometryElement::eByPolygon); + elementMaterial->SetReferenceMode(FbxGeometryElement::eIndexToDirect); } void -bindMaterial(ExportFbxContext& ctx, const Mesh& mesh, FbxMesh* fbxMesh) +bindMaterial(ExportFbxContext& ctx, size_t meshIndex, FbxNode* n) { - if (mesh.material >= 0) { - FbxSurfaceMaterial* material = ctx.materials[mesh.material]; - FbxNode* n = fbxMesh->GetNode(); - if (material && n) + // The target node is passed in explicitly rather than derived from + // fbxMesh->GetNode(): a single FbxMesh can be shared across several + // FbxNodes (USD instanceable meshes, PointInstancer prototypes) and + // GetNode() only returns the first one, so materials must be attached to + // the specific node carrying this mesh instance. + if (!n) { + return; + } + const std::vector& order = ctx.meshNodeMaterialOrder[meshIndex]; + if (order.empty()) { + // Either no subsets and mesh.material < 0, or no valid bindings at all. + const Mesh& mesh = ctx.usd->meshes[meshIndex]; + if (mesh.material >= 0 && static_cast(mesh.material) < ctx.materials.size()) { + if (FbxSurfaceMaterial* material = ctx.materials[mesh.material]) { + n->AddMaterial(material); + } + } + return; + } + for (int usdMatIdx : order) { + if (usdMatIdx < 0 || static_cast(usdMatIdx) >= ctx.materials.size()) { + continue; + } + if (FbxSurfaceMaterial* material = ctx.materials[usdMatIdx]) { n->AddMaterial(material); + } } } @@ -570,17 +744,28 @@ bool exportFbxMeshes(ExportFbxContext& ctx) { ctx.meshes.resize(ctx.usd->meshes.size()); + ctx.meshNodeMaterialOrder.resize(ctx.usd->meshes.size()); + ctx.meshPolyMatIdx.resize(ctx.usd->meshes.size()); for (size_t i = 0; i < ctx.usd->meshes.size(); i++) { const Mesh& m = ctx.usd->meshes[i]; FbxMesh* fbxMesh = FbxMesh::Create(ctx.fbx->scene, getNodeName(m).c_str()); if (fbxMesh != nullptr) { ctx.meshes[i] = fbxMesh; - createMeshMaterial(ctx, m, fbxMesh); - - // Positions + createMeshMaterial(ctx, i, fbxMesh); + const std::vector& polyMatIdx = ctx.meshPolyMatIdx[i]; + const bool perPolygonMaterial = !polyMatIdx.empty(); + + // Positions. When per-polygon material assignment is enabled, the + // FBX SDK requires the material slot index to be passed as the + // BeginPolygon argument; that's how it wires up the material + // element's IndexArray for serialization. size_t k = 0; for (size_t j = 0; j < m.faces.size(); j++) { - fbxMesh->BeginPolygon(); + if (perPolygonMaterial && j < polyMatIdx.size()) { + fbxMesh->BeginPolygon(polyMatIdx[j]); + } else { + fbxMesh->BeginPolygon(); + } for (int l = 0; l < m.faces[j]; l++) { fbxMesh->AddPolygon(m.indices[k++]); } @@ -1038,12 +1223,10 @@ void exportFbxMaterials(ExportFbxContext& ctx) { InputTranslator inputTranslator(true, ctx.usd->images, DEBUG_TAG); - ctx.materials.resize(ctx.usd->materials.size()); - for (size_t i = 0; i < ctx.usd->materials.size(); i++) { - const Material& m = ctx.usd->materials[i]; - FbxSurfacePhong* phong = FbxSurfacePhong::Create(ctx.fbx->scene, getNodeName(m).c_str()); - ctx.materials[i] = phong; - + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + size_t matCount = useOpenPbr ? ctx.usd->openPbrMaterials.size() : ctx.usd->materials.size(); + ctx.materials.resize(matCount); + for (size_t i = 0; i < matCount; i++) { Input diffuseColor; Input transparency; Input normal; @@ -1052,16 +1235,36 @@ exportFbxMaterials(ExportFbxContext& ctx) Input metallic; Input roughness; - inputTranslator.translateDirect(m.diffuseColor, diffuseColor); - inputTranslator.translateOpacity2Transparency(m.opacity, transparency); - inputTranslator.translateDirect(m.normal, normal); - inputTranslator.translateDirect(m.emissiveColor, emissiveColor); - // Convert Input data for occlusion, metallic and roughness to single channel textures - // (if necessary). This is done so that there is consistency on which channel to - // reference when importing. - inputTranslator.translateToSingle("occlusion", m.occlusion, occlusion); - inputTranslator.translateToSingle("metallic", m.metallic, metallic); - inputTranslator.translateToSingle("roughness", m.roughness, roughness); + std::string matName; + if (useOpenPbr) { + const OpenPbrMaterial& m = ctx.usd->openPbrMaterials[i]; + matName = getNodeName(m); + inputTranslator.translateDirect(m.base_color, diffuseColor); + inputTranslator.translateOpacity2Transparency(m.geometry_opacity, transparency); + inputTranslator.translateDirect(m.geometry_normal, normal); + inputTranslator.translateDirect(m.emission_color, emissiveColor); + // Convert Input data for occlusion, metallic and roughness to single channel textures + // (if necessary). This is done so that there is consistency on which channel to + // reference when importing. + inputTranslator.translateToSingle("occlusion", m.occlusion, occlusion); + inputTranslator.translateToSingle("metallic", m.base_metalness, metallic); + inputTranslator.translateToSingle("roughness", m.specular_roughness, roughness); + } else { + const Material& m = ctx.usd->materials[i]; + matName = getNodeName(m); + inputTranslator.translateDirect(m.diffuseColor, diffuseColor); + inputTranslator.translateOpacity2Transparency(m.opacity, transparency); + inputTranslator.translateDirect(m.normal, normal); + inputTranslator.translateDirect(m.emissiveColor, emissiveColor); + // Convert Input data for occlusion, metallic and roughness to single channel textures + // (if necessary). This is done so that there is consistency on which channel to + // reference when importing. + inputTranslator.translateToSingle("occlusion", m.occlusion, occlusion); + inputTranslator.translateToSingle("metallic", m.metallic, metallic); + inputTranslator.translateToSingle("roughness", m.roughness, roughness); + } + FbxSurfacePhong* phong = FbxSurfacePhong::Create(ctx.fbx->scene, matName.c_str()); + ctx.materials[i] = phong; exportFbxInput(ctx, inputTranslator, diffuseColor, @@ -1155,8 +1358,9 @@ exportSkeletons(ExportFbxContext& ctx) // Also, link nodes to the meshes control points via the fbx clusters. for (size_t j = 0; j < skeleton.meshSkinningTargets.size(); j++) { int meshTargetIndex = skeleton.meshSkinningTargets[j]; - if (meshTargetIndex < 0 || meshTargetIndex >= ctx.usd->meshes.size() || - meshTargetIndex >= ctx.meshes.size()) { + if (meshTargetIndex < 0 || + meshTargetIndex >= static_cast(ctx.usd->meshes.size()) || + meshTargetIndex >= static_cast(ctx.meshes.size())) { TF_RUNTIME_ERROR( FILE_FORMAT_FBX, "Invalid target index: %d\n", meshTargetIndex); continue; @@ -1193,7 +1397,7 @@ exportSkeletons(ExportFbxContext& ctx) size_t currentVertex = k / mesh.influenceCount; float weight = mesh.weights[k]; int joint = mesh.joints[k]; - if (joint < 0 || joint >= clusters.size()) { + if (joint < 0 || joint >= static_cast(clusters.size())) { TF_RUNTIME_ERROR(FILE_FORMAT_FBX, "Invalid joint index: %d\n", joint); continue; } @@ -1239,7 +1443,7 @@ exportSkeletons(ExportFbxContext& ctx) FbxTime fbxTime; fbxTime.SetSecondDouble(time * secondsPerTimeCode); TF_DEBUG_MSG(FILE_FORMAT_FBX, - "export skeleton[%lu] animation[%lu][t = %f]: %lu joints\n", + "export skeleton[%zu] animation[%d][t = %f]: %zu joints\n", i, animationTrackIndex, time, @@ -1370,6 +1574,14 @@ exportFbxNodes(ExportFbxContext& ctx) const Skeleton& skeleton = ctx.usd->skeletons[skeletonIndex]; for (int skinningTargetIdx : skeleton.meshSkinningTargets) { + if (skinningTargetIdx < 0 || + skinningTargetIdx >= static_cast(ctx.usd->meshes.size()) || + skinningTargetIdx >= static_cast(ctx.meshes.size())) { + TF_RUNTIME_ERROR(FILE_FORMAT_FBX, + "Invalid skinning target mesh index: %d\n", + skinningTargetIdx); + continue; + } const Mesh& mesh = ctx.usd->meshes[skinningTargetIdx]; FbxNode* fbxMeshNode = FbxNode::Create(ctx.fbx->scene, getNodeName(mesh).c_str()); @@ -1379,7 +1591,7 @@ exportFbxNodes(ExportFbxContext& ctx) FbxMesh* fbxMesh = ctx.meshes[skinningTargetIdx]; if (fbxMesh != nullptr) { fbxMeshNode->AddNodeAttribute(fbxMesh); - bindMaterial(ctx, mesh, fbxMesh); + bindMaterial(ctx, skinningTargetIdx, fbxMeshNode); } else { TF_WARN("Invalid mesh: %d", skinningTargetIdx); } @@ -1388,8 +1600,8 @@ exportFbxNodes(ExportFbxContext& ctx) } for (size_t i = 0; i < node.staticMeshes.size(); i++) { int meshIndex = node.staticMeshes[i]; - if (meshIndex < 0 || meshIndex >= ctx.usd->meshes.size() || - meshIndex >= ctx.meshes.size()) { + if (meshIndex < 0 || meshIndex >= static_cast(ctx.usd->meshes.size()) || + meshIndex >= static_cast(ctx.meshes.size())) { TF_RUNTIME_ERROR(FILE_FORMAT_FBX, "Invalid mesh index: %d\n", meshIndex); continue; } @@ -1412,7 +1624,7 @@ exportFbxNodes(ExportFbxContext& ctx) FbxMesh* fbxMesh = ctx.meshes[meshIndex]; if (fbxMesh != nullptr) { container->AddNodeAttribute(fbxMesh); - bindMaterial(ctx, m, fbxMesh); + bindMaterial(ctx, meshIndex, container); } else { TF_WARN("Invalid mesh: %d", meshIndex); } diff --git a/fbx/src/fbxImport.cpp b/fbx/src/fbxImport.cpp index 84e915d0..003056be 100644 --- a/fbx/src/fbxImport.cpp +++ b/fbx/src/fbxImport.cpp @@ -12,7 +12,10 @@ governing permissions and limitations under the License. #include "fbxImport.h" #include "debugCodes.h" +#include +#include #include +#include #include #include #include @@ -183,6 +186,21 @@ importFbxTransform(ImportFbxContext& ctx, GfVec3f& s, bool useGlobalTransform) { + // Returns true when any scale component is near-zero. We check both the + // FBX property directly AND the decomposed scale because: + // - UsdSkelDecomposeTransform may return non-zero scale for degenerate + // matrices on some USD versions (public OpenUSD) + // - Real FBX files may have zero effective scale from animation keys or + // parent inheritance rather than the static LclScaling property + auto hasNearZeroFbxScale = [](const FbxDouble3& s) { + constexpr double kMinScaleSq = 1e-12; + return s[0] * s[0] < kMinScaleSq || s[1] * s[1] < kMinScaleSq || s[2] * s[2] < kMinScaleSq; + }; + auto hasNearZeroDecomposedScale = [](const GfVec3f& s) { + constexpr double kMinScaleSq = 1e-12; + return s[0] * s[0] < kMinScaleSq || s[1] * s[1] < kMinScaleSq || s[2] * s[2] < kMinScaleSq; + }; + // Helper function to decompose the transformation matrix into translation, rotation, and scale auto decomposeTransformation = [](GfVec3f& translation, GfQuatf& rotation, @@ -200,7 +218,8 @@ importFbxTransform(ImportFbxContext& ctx, bool isAnimatedSkeletonNode = (ctx.animatedSkeletonNodes.find(fbxNode) != ctx.animatedSkeletonNodes.end()); if (!isAnimatedSkeletonNode) { - for (int animationStackIndex = 0; animationStackIndex < ctx.animationStacks.size(); + for (int animationStackIndex = 0; + animationStackIndex < static_cast(ctx.animationStacks.size()); animationStackIndex++) { // Set the current animation stack so that EvaluateLocalTransform will return the // correct value @@ -281,6 +300,15 @@ importFbxTransform(ImportFbxContext& ctx, ? fbxNode->EvaluateGlobalTransform(keyFrameTime) : fbxNode->EvaluateLocalTransform(keyFrameTime)); + // Near-zero scale makes the composed matrix singular, so the + // decomposed rotation is unreliable. Fall back to FBX's + // separate rotation channel. + if (hasNearZeroFbxScale(fbxNode->LclScaling.EvaluateValue(keyFrameTime)) || + hasNearZeroDecomposedScale(scale)) { + rotation = toQuatf(fbxNode->LclRotation.EvaluateValue(keyFrameTime)); + rotation.Normalize(); + } + nodeAnimation.translations.times.push_back(time); nodeAnimation.translations.values.push_back(translation); @@ -303,7 +331,17 @@ importFbxTransform(ImportFbxContext& ctx, scale, useGlobalTransform ? fbxNode->EvaluateGlobalTransform() : fbxNode->EvaluateLocalTransform()); + + // When any scale component is near-zero the composed matrix is singular and + // UsdSkelDecomposeTransform produces an arbitrary rotation. Fall back to + // FBX's LclRotation channel which gives the authored local rotation + // regardless of whether this is a root child or nested node. + if (hasNearZeroFbxScale(fbxNode->LclScaling.Get()) || hasNearZeroDecomposedScale(scale)) { + rotation = toQuatf(fbxNode->LclRotation.Get()); + rotation.Normalize(); + } } + node.translation = translation; node.rotation = rotation; node.scale = scale; @@ -748,6 +786,19 @@ importFbxMesh(ImportFbxContext& ctx, FbxMesh* fbxMesh, int parent) trimDegenerateNormals(mesh); FbxNode* fbxNode = fbxMesh->GetNode(); + // When a mesh is shared across multiple FBX nodes (e.g. C4D instances), GetNode() returns + // the first connected node, which may be an instance without a material connection. Fall + // back to the first parent node that actually has materials assigned. + if (fbxNode != nullptr && fbxNode->GetMaterialCount() == 0) { + int nodeCount = fbxMesh->GetNodeCount(); + for (int ni = 0; ni < nodeCount; ni++) { + FbxNode* candidate = fbxMesh->GetNode(ni); + if (candidate != nullptr && candidate->GetMaterialCount() > 0) { + fbxNode = candidate; + break; + } + } + } if (fbxNode != nullptr) { int materialCount = fbxNode->GetMaterialCount(); int elementMaterialCount = fbxMesh->GetElementMaterialCount(); @@ -1049,6 +1100,601 @@ _toCamelCase(std::string s) noexcept return s; } +// Inverse of _toCamelCase: convert a camelCase identifier to snake_case. Existing underscores are +// preserved, so a name that is already snake_case (e.g. 3ds Max's "base_color") passes through +// unchanged. An underscore is inserted before an uppercase letter that starts a new word, which +// also splits trailing acronyms (e.g. "specularIOR" -> "specular_ior"). +std::string +_toSnakeCase(const std::string& s) noexcept +{ + std::string out; + out.reserve(s.size() + 8); + for (std::size_t i = 0; i < s.size(); ++i) { + unsigned char c = s[i]; + if (std::isupper(c)) { + const bool prevIsWord = i > 0 && (std::islower(static_cast(s[i - 1])) || + std::isdigit(static_cast(s[i - 1]))); + const bool nextIsLower = + i + 1 < s.size() && std::islower(static_cast(s[i + 1])); + if (i > 0 && (prevIsWord || nextIsLower) && !out.empty() && out.back() != '_') { + out.push_back('_'); + } + out.push_back(static_cast(std::tolower(c))); + } else { + out.push_back(static_cast(c)); + } + } + return out; +} + +// This is designed to identify if a material contains a "Autodesk Standard Surface" defintion and +// to process it for mapping to our material model. There isn't a reliable direct way to check this, +// so we need to check the properties of the material to see if they match the standard surface +// material. The assumption is that if a materal contains all the properties we are expecting to +// see on a standard surface shader, it's safe to assume it is a standard surface shader. There are +// 3 known ways to generate a shader that is a standard surface shader using the Autodesk tools: +// +// 1.) In Maya you define a "Standard Surface" shader which is Renderer agnostic +// +// 2.) In Maya you can define a Arnold variant of the standard surface shader, specially called "Ai +// Standard Surface" shader which is a standard surface shader designed to work with Arnold +// +// 3.) Finally in 3ds max you can define a "Standard Surface" shader, which is an Arnold shader +// +// It's worth nothing that although both Maya and Max can produce an Arnold Standard Surface shader, +// the actual FBX file looks very different between the two and you can't even interop the FBX file +// between Maya and Max. But regardless we're able to use both of them in this utility by just +// relying on the properties being present and treating them effectively the same here. +// +// Returns true if the material was a standard surface shader and was successfully processed +bool +_mapAutodeskStandardMaterialOpenPbr(const FbxSurfaceMaterial* fbxMaterial, + ImportFbxContext& ctx, + const std::unordered_map& textures, + OpenPbrMaterial& usdMaterial, + InputTranslator& inputTranslator) +{ + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Checking if %s is an Autodesk Standard Surface Material\n", + fbxMaterial->GetName()); + // Determine the effective colorspace for color properties based on the originalColorSpace + // option. If originalColorSpace is set to sRGB, color data will be converted to linear and + // stored as raw. If originalColorSpace is not set, no conversion happens and data is passed + // through as raw (unknown colorspace - let the client application handle color management). + const TfToken& colorPropertySpace = + (ctx.originalColorSpace == AdobeTokens->sRGB) ? AdobeTokens->sRGB : AdobeTokens->raw; + + // This will contain the properties that are directly mapped from the standard surface exactly + // as is. We need to note if they are One or Three channels and the colorspace for later usage. + std::unordered_map> + standardSurfToUsdProperty = { + { "base_color", + { usdMaterial.base_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "specular_color", + { usdMaterial.specular_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "metalness", + { usdMaterial.base_metalness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "specular_roughness", + { usdMaterial.specular_roughness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "coat", { usdMaterial.coat_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "coat_color", + { usdMaterial.coat_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "coat_roughness", + { usdMaterial.coat_roughness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "coat_IOR", { usdMaterial.coat_ior, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "sheen_color", + { usdMaterial.fuzz_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "sheen_roughness", + { usdMaterial.fuzz_roughness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "specular_anisotropy", + { usdMaterial.specular_roughness_anisotropy, + FbxPropertyNumChannels::One, + AdobeTokens->raw } }, + { "specular_rotation", + { usdMaterial.anisotropyAngle, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "specular_IOR", + { usdMaterial.specular_ior, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "transmission", + { usdMaterial.transmission_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "transmission_depth", + { usdMaterial.transmission_depth, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "transmission_color", + { usdMaterial.transmission_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "subsurface_color", + { usdMaterial.subsurface_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + }; + + // Make a set that has all the properties we want to validate to confirm this is a standard + // surface. Will contain the above set with some additional properties we will need special + // case handling for later on + const std::string kEmission = "emission"; + const std::string kEmissionColor = "emission_color"; + const std::string kNormalCamera = "normal_camera"; + const std::string kCoatNormal = "coat_normal"; + const std::string kOpacity = "opacity"; + std::set validatedStandardSurfProperties; + for (auto& it : standardSurfToUsdProperty) { + validatedStandardSurfProperties.insert(it.first); + } + validatedStandardSurfProperties.insert(kEmission); + validatedStandardSurfProperties.insert(kEmissionColor); + validatedStandardSurfProperties.insert(kNormalCamera); + validatedStandardSurfProperties.insert(kCoatNormal); + validatedStandardSurfProperties.insert(kOpacity); + + // Some implementations of the standard surface use camel case for the properties instead of + // snake case, so we need to check both permutations + auto getProp = [&fbxMaterial](const std::string& name) -> FbxProperty { + FbxProperty property = FbxSurfaceMaterialUtils::GetProperty(name.c_str(), fbxMaterial); + if (!property.IsValid()) { + std::string camelCaseProp = _toCamelCase(name); + property = FbxSurfaceMaterialUtils::GetProperty(camelCaseProp.c_str(), fbxMaterial); + } + return property; + }; + + // Do a two-pass strategy so we don't map any channels until we've confirmed it is a standard + // surface shader. Basically it's all or nothing if the standard surface shader is used or not + for (auto& it : validatedStandardSurfProperties) { + TF_DEBUG_MSG(FILE_FORMAT_FBX, "Looking for standard surface property %s\n", it.c_str()); + auto property = getProp(it); + if (!property.IsValid()) { + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Standard surface property %s was not found, assuming this is not an " + "instance of the autodesk standard surface shader\n", + it.c_str()); + return false; + } + } + + // If we got here then we assume this is one of the standard shader variants because it had all + // of the properties we are expecting to see and use to map to USD + for (auto& it : standardSurfToUsdProperty) { + FbxProperty property = getProp(it.first); + Input& input = std::get<0>(it.second); + FbxPropertyNumChannels numChannels = std::get<1>(it.second); + const TfToken& colorSpace = std::get<2>(it.second); + if (numChannels == FbxPropertyNumChannels::One) { + auto typedProp = static_cast>(property); + Input tempInput; + importPropTexture(ctx, textures, fbxMaterial, typedProp, tempInput, "r", colorSpace); + inputTranslator.translateDirect(tempInput, input); + } else if (numChannels == FbxPropertyNumChannels::Three) { + auto typedProp = static_cast>(property); + Input tempInput; + importPropTexture(ctx, textures, fbxMaterial, typedProp, tempInput, "rgb", colorSpace); + inputTranslator.translateDirect(tempInput, input); + } else { + TF_CODING_ERROR("Unknown number of channels"); + } + } + + // Special case handling for additional properties that aren't directly mapped + + // Only include normal maps if they are defined as non empty file path strings, otherwise the + // empty type wouldn't be handled by USD properly + auto normalCameraProperty = getProp(kNormalCamera); + auto normalCameraTexture = FbxCast(normalCameraProperty.GetSrcObject()); + if (normalCameraTexture) { + auto typedProp = static_cast>(normalCameraProperty); + Input input; + importPropTexture(ctx, textures, fbxMaterial, typedProp, input, "rgb", AdobeTokens->raw); + inputTranslator.translateDirect(input, usdMaterial.geometry_normal); + } + + auto coatNormalProperty = getProp(kCoatNormal); + auto coatNormalTexture = FbxCast(coatNormalProperty.GetSrcObject()); + if (coatNormalTexture) { + auto typedProp = static_cast>(coatNormalProperty); + Input input; + importPropTexture(ctx, textures, fbxMaterial, typedProp, input, "rgb", AdobeTokens->raw); + inputTranslator.translateDirect(input, usdMaterial.geometry_coat_normal); + } + + auto emissionProperty = static_cast>(getProp(kEmission)); + Input emissionInput; + importPropTexture( + ctx, textures, fbxMaterial, emissionProperty, emissionInput, "r", AdobeTokens->raw); + + auto emissionColorProperty = static_cast>(getProp(kEmissionColor)); + Input emissionColorInput; + importPropTexture(ctx, + textures, + fbxMaterial, + emissionColorProperty, + emissionColorInput, + "rgb", + colorPropertySpace); + + // XXX I believe a more proper way to do this is to keep the emissive intensity as a + // separate input because in UIs that use a color picker to modify this input you will lose + // values over one upon modification. This matches how GLTF handles it though currently, and I + // think this also is an issue there as well + inputTranslator.translateFactor(emissionColorInput, emissionInput, usdMaterial.emission_color); + + // Opacity in USD must be stored as a single value + auto opacityProperty = getProp(kOpacity); + if (opacityProperty.IsValid()) { + auto opacityTypedProp = static_cast>(opacityProperty); + GfVec3f opacityColor = readPropValue(opacityTypedProp); + + // Convert the opacity color to grayscale and use that as the opacity value + float grayscaleOpacity = (opacityColor[0] + opacityColor[1] + opacityColor[2]) / 3.0f; + usdMaterial.geometry_opacity.value = grayscaleOpacity; + usdMaterial.geometry_opacity.colorspace = AdobeTokens->raw; + } + + return true; +} + +// Maya 2026 and 3ds Max 2026 both export native OpenPBR materials into FBX, and both emit the full +// canonical OpenPBR parameter set prefixed with a vendor token. They differ in the details: +// +// - Maya ("openPBRSurface"): ShadingModel "openpbrsurface", properties under the "Maya|" compound +// with camelCase leaves ("baseColor"), colors as Vector3D (FbxDouble3), and an +// FbxImplementation whose binding table authoritatively maps each "Maya|" to its +// canonical OpenPBR name. +// - 3ds Max: ShadingModel "unknown", properties under "3dsMax|Parameters|" with snake_case leaves +// already matching the OpenPBR spec ("base_color"), colors as ColorAndAlpha (FbxDouble4), and +// no binding table. Textures live on separate "_map" + "_map_on" slots. +// +// Rather than guess by property index or maintain per-channel alias lists, this resolves every +// value to a canonical OpenPBR name (binding table when present, otherwise prefix stripping via the +// recursive descendant walk plus camelCase->snake_case normalization) and looks it up in a single +// target table. Returns true if the material was recognized as OpenPBR-authored and processed. +bool +_mapDccOpenPbrMaterialOpenPbr(const FbxSurfaceMaterial* fbxMaterial, + ImportFbxContext& ctx, + const std::unordered_map& textures, + OpenPbrMaterial& usdMaterial, + InputTranslator& inputTranslator) +{ + const TfToken& colorPropertySpace = + (ctx.originalColorSpace == AdobeTokens->sRGB) ? AdobeTokens->sRGB : AdobeTokens->raw; + + // Canonical OpenPBR (snake_case) name -> destination field, channel count, colorspace. + std::unordered_map> + targetTable = { + { "base_weight", + { usdMaterial.base_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "base_color", + { usdMaterial.base_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "base_diffuse_roughness", + { usdMaterial.base_diffuse_roughness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "base_metalness", + { usdMaterial.base_metalness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "specular_weight", + { usdMaterial.specular_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "specular_color", + { usdMaterial.specular_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "specular_roughness", + { usdMaterial.specular_roughness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "specular_ior", + { usdMaterial.specular_ior, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "specular_roughness_anisotropy", + { usdMaterial.specular_roughness_anisotropy, + FbxPropertyNumChannels::One, + AdobeTokens->raw } }, + { "transmission_weight", + { usdMaterial.transmission_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "transmission_color", + { usdMaterial.transmission_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "transmission_depth", + { usdMaterial.transmission_depth, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "transmission_scatter", + { usdMaterial.transmission_scatter, + FbxPropertyNumChannels::Three, + colorPropertySpace } }, + { "transmission_scatter_anisotropy", + { usdMaterial.transmission_scatter_anisotropy, + FbxPropertyNumChannels::One, + AdobeTokens->raw } }, + { "transmission_dispersion_scale", + { usdMaterial.transmission_dispersion_scale, + FbxPropertyNumChannels::One, + AdobeTokens->raw } }, + { "transmission_dispersion_abbe_number", + { usdMaterial.transmission_dispersion_abbe_number, + FbxPropertyNumChannels::One, + AdobeTokens->raw } }, + { "subsurface_weight", + { usdMaterial.subsurface_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "subsurface_color", + { usdMaterial.subsurface_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "subsurface_radius", + { usdMaterial.subsurface_radius, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "subsurface_radius_scale", + { usdMaterial.subsurface_radius_scale, + FbxPropertyNumChannels::Three, + AdobeTokens->raw } }, + { "subsurface_scatter_anisotropy", + { usdMaterial.subsurface_scatter_anisotropy, + FbxPropertyNumChannels::One, + AdobeTokens->raw } }, + { "fuzz_weight", + { usdMaterial.fuzz_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "fuzz_color", + { usdMaterial.fuzz_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "fuzz_roughness", + { usdMaterial.fuzz_roughness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "coat_weight", + { usdMaterial.coat_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "coat_color", + { usdMaterial.coat_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "coat_roughness", + { usdMaterial.coat_roughness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "coat_roughness_anisotropy", + { usdMaterial.coat_roughness_anisotropy, + FbxPropertyNumChannels::One, + AdobeTokens->raw } }, + { "coat_ior", { usdMaterial.coat_ior, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "coat_darkening", + { usdMaterial.coat_darkening, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "thin_film_weight", + { usdMaterial.thin_film_weight, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "thin_film_thickness", + { usdMaterial.thin_film_thickness, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "thin_film_ior", + { usdMaterial.thin_film_ior, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "emission_luminance", + { usdMaterial.emission_luminance, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "emission_color", + { usdMaterial.emission_color, FbxPropertyNumChannels::Three, colorPropertySpace } }, + { "geometry_opacity", + { usdMaterial.geometry_opacity, FbxPropertyNumChannels::One, AdobeTokens->raw } }, + { "geometry_normal", + { usdMaterial.geometry_normal, FbxPropertyNumChannels::Three, AdobeTokens->raw } }, + { "geometry_coat_normal", + { usdMaterial.geometry_coat_normal, FbxPropertyNumChannels::Three, AdobeTokens->raw } }, + }; + + // Build the authoritative binding-table map (Maya). Source is the full prefixed property name + // ("Maya|baseColor"), destination the canonical OpenPBR name ("base_color"). Also note whether + // any implementation declares the OpenPbrSL shading language, which is a strong detection + // signal. Implementations are connected as destination objects of the material. + std::unordered_map bindingMap; + bool hasOpenPbrImplementation = false; + const int implCount = fbxMaterial->GetDstObjectCount(); + for (int j = 0; j < implCount; ++j) { + const FbxImplementation* impl = fbxMaterial->GetDstObject(j); + if (!impl) { + continue; + } + // A material may carry several implementations (e.g. 3ds Max emits a MentalRay one + // alongside OpenPBR). Only trust the OpenPbrSL binding table so unrelated mappings don't + // leak in. + if (std::string(impl->Language.Get().Buffer()) != "OpenPbrSL") { + continue; + } + hasOpenPbrImplementation = true; + const FbxBindingTable* table = impl->GetRootTable(); + if (!table) { + continue; + } + for (size_t k = 0; k < table->GetEntryCount(); ++k) { + const FbxBindingTableEntry& entry = table->GetEntry(k); + const char* source = entry.GetSource(); + const char* destination = entry.GetDestination(); + if (source && destination) { + bindingMap[source] = destination; + } + } + } + + // Resolve a property to its canonical OpenPBR name: binding table first, otherwise normalize + // the leaf name to snake_case (a no-op for 3ds Max's already-snake_case leaves). + auto canonicalName = [&](const FbxProperty& prop) -> std::string { + auto it = bindingMap.find(prop.GetHierarchicalName().Buffer()); + if (it != bindingMap.end()) { + return it->second; + } + return _toSnakeCase(prop.GetName().Buffer()); + }; + + // Detection: only claim this material when there is an unambiguous OpenPBR signal, so we don't + // intercept other shaders (e.g. Arnold "Standard Surface") that happen to share property names + // like base_color and specular_roughness. The two reliable signals from real Maya 2026 / 3ds + // Max 2026 exports are the Maya "openpbrsurface" shading model and an OpenPbrSL + // FbxImplementation (which both DCCs emit, carrying the binding table). Materials without + // either fall through to the existing standard-surface and heuristic handlers unchanged. + FbxProperty shadingModelProp = + FbxSurfaceMaterialUtils::GetProperty(FbxSurfaceMaterial::sShadingModel, fbxMaterial); + std::string shadingModel = + shadingModelProp.IsValid() ? shadingModelProp.Get().Buffer() : ""; + std::transform(shadingModel.begin(), + shadingModel.end(), + shadingModel.begin(), + [](unsigned char c) { return std::tolower(c); }); + + if (shadingModel != "openpbrsurface" && !hasOpenPbrImplementation) { + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Material '%s' has no OpenPBR signal, not handling as DCC OpenPBR\n", + fbxMaterial->GetName()); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Processing '%s' as DCC OpenPBR material (binding entries=%zu)\n", + fbxMaterial->GetName(), + bindingMap.size()); + + const bool convertToLinear = (ctx.originalColorSpace == AdobeTokens->sRGB); + + // 3ds Max stores textures on separate "_map" slots gated by a "_map_on" bool. + std::unordered_map mapTextureProps; + std::unordered_map mapEnabled; + auto endsWith = [](const std::string& s, const std::string& suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + + // Maya wires the normal through the legacy normalCamera attribute (bound as "normalCamera", + // not "geometry_normal"), with a sibling normalCameraUsedAs that says whether it's a bump (0) + // or a tangent-space normal map (1). Capture these and resolve after the loop. + FbxProperty normalCameraProp; + FbxProperty coatNormalProp; + int normalCameraUsedAs = 0; + + bool foundAnyProperties = false; + for (FbxProperty prop = fbxMaterial->GetFirstProperty(); prop.IsValid(); + prop = fbxMaterial->GetNextProperty(prop)) { + const std::string leaf = prop.GetName().Buffer(); + + // Collect 3ds Max texture-map slots for a second pass. + if (endsWith(leaf, "_map_on")) { + mapEnabled[_toSnakeCase(leaf.substr(0, leaf.size() - 7))] = prop.Get(); + continue; + } + if (endsWith(leaf, "_map")) { + if (FbxCast(prop.GetSrcObject())) { + mapTextureProps[_toSnakeCase(leaf.substr(0, leaf.size() - 4))] = prop; + } + continue; + } + + // Maya normal inputs: capture the texture-bearing normalCamera / geometryCoatNormal and the + // bump-vs-normal flag; routed to geometry_normal / geometry_coat_normal after the loop. + if (leaf == "normalCameraUsedAs") { + normalCameraUsedAs = static_cast(static_cast>(prop).Get()); + continue; + } + if (leaf == "normalCamera") { + if (FbxCast(prop.GetSrcObject())) { + normalCameraProp = prop; + } + continue; + } + if (leaf == "geometryCoatNormal") { + if (FbxCast(prop.GetSrcObject())) { + coatNormalProp = prop; + } + continue; + } + + const std::string canonical = canonicalName(prop); + const EFbxType dataType = prop.GetPropertyDataType().GetType(); + + if (canonical == "geometry_thin_walled" && dataType == eFbxBool) { + auto typed = static_cast>(prop); + usdMaterial.geometry_thin_walled.value = static_cast(typed.Get()); + usdMaterial.geometry_thin_walled.colorspace = AdobeTokens->raw; + foundAnyProperties = true; + continue; + } + + auto target = targetTable.find(canonical); + if (target == targetTable.end()) { + continue; + } + Input& input = std::get<0>(target->second); + const FbxPropertyNumChannels numChannels = std::get<1>(target->second); + const TfToken& colorSpace = std::get<2>(target->second); + + Input tempInput; + if (numChannels == FbxPropertyNumChannels::Three) { + if (dataType == eFbxDouble3) { + auto typed = static_cast>(prop); + importPropTexture(ctx, textures, fbxMaterial, typed, tempInput, "rgb", colorSpace); + } else if (dataType == eFbxDouble4) { + // ColorAndAlpha: take RGB, drop alpha, and mirror importPropTexture's colorspace + // handling so 3ds Max colors land in the same space as Maya's. + auto typed = static_cast>(prop); + FbxDouble4 rgba = typed.Get(); + tempInput.value = GfVec3f(rgba[0], rgba[1], rgba[2]); + if (convertToLinear && colorSpace == AdobeTokens->sRGB) { + tempInput.value = srgbToLinear(tempInput.value); + } + tempInput.colorspace = convertToLinear ? AdobeTokens->raw : colorSpace; + } else { + continue; + } + } else if (numChannels == FbxPropertyNumChannels::One) { + if (dataType != eFbxDouble && dataType != eFbxFloat) { + continue; + } + // Read both Double and Float scalars through an FbxDouble view (the SDK coerces float) + // so importPropTexture also picks up a texture wired onto the scalar property itself, + // which is how Maya attaches roughness/metalness/coat maps. + auto typed = static_cast>(prop); + importPropTexture(ctx, textures, fbxMaterial, typed, tempInput, "r", colorSpace); + // If the property held its registered default and had no texture, importPropTexture + // leaves the value empty; record the scalar explicitly so e.g. base_metalness=0 sticks. + if (tempInput.value.IsEmpty() && tempInput.image < 0) { + tempInput.value = static_cast(typed.Get()); + tempInput.colorspace = AdobeTokens->raw; + } + } else { + // Two (or any future channel count) is not mappable here; skip rather than + // translate a default-constructed empty input. + continue; + } + inputTranslator.translateDirect(tempInput, input); + foundAnyProperties = true; + } + + // Apply collected 3ds Max texture maps to their targets, honoring the "_map_on" toggle. + for (auto& it : mapTextureProps) { + const std::string& base = it.first; + auto enabled = mapEnabled.find(base); + if (enabled != mapEnabled.end() && !enabled->second) { + continue; + } + auto target = targetTable.find(base); + if (target == targetTable.end()) { + continue; + } + Input& input = std::get<0>(target->second); + const FbxPropertyNumChannels numChannels = std::get<1>(target->second); + const TfToken& colorSpace = std::get<2>(target->second); + + Input tempInput; + auto typed = static_cast>(it.second); + importPropTexture(ctx, + textures, + fbxMaterial, + typed, + tempInput, + numChannels == FbxPropertyNumChannels::Three ? "rgb" : "r", + colorSpace); + if (tempInput.image >= 0) { + inputTranslator.translateDirect(tempInput, input); + foundAnyProperties = true; + } + } + + // Maya base normal. Only route it when authored as a tangent-space normal map + // (Use As: Tangent Space Normals -> normalCameraUsedAs == 1); a bump/height map (0) or + // object-space normal is not a tangent-space geometry_normal and is left alone. + if (normalCameraProp.IsValid() && normalCameraUsedAs == 1) { + Input tempInput; + auto typed = static_cast>(normalCameraProp); + importPropTexture(ctx, textures, fbxMaterial, typed, tempInput, "rgb", AdobeTokens->raw); + if (tempInput.image >= 0) { + inputTranslator.translateDirect(tempInput, usdMaterial.geometry_normal); + foundAnyProperties = true; + } + } else if (normalCameraProp.IsValid()) { + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Material '%s' normalCamera is not a tangent-space normal map " + "(normalCameraUsedAs=%d); not routed to geometry_normal\n", + fbxMaterial->GetName(), + normalCameraUsedAs); + } + + // Maya coat normal (geometryCoatNormal) is always a normal input when textured. + if (coatNormalProp.IsValid()) { + Input tempInput; + auto typed = static_cast>(coatNormalProp); + importPropTexture(ctx, textures, fbxMaterial, typed, tempInput, "rgb", AdobeTokens->raw); + if (tempInput.image >= 0) { + inputTranslator.translateDirect(tempInput, usdMaterial.geometry_coat_normal); + foundAnyProperties = true; + } + } + + return foundAnyProperties; +} + // This is designed to identify if a material contains a "Autodesk Standard Surface" defintion and // to process it for mapping to our material model. There isn't a reliable direct way to check this, // so we need to check the properties of the material to see if they match the standard surface @@ -1225,7 +1871,7 @@ _mapAutodeskStandardMaterial(const FbxSurfaceMaterial* fbxMaterial, "rgb", colorPropertySpace); - // XXX @dcoffey I believe a more proper way to do this is to keep the emissive intensity as a + // XXX I believe a more proper way to do this is to keep the emissive intensity as a // separate input because in UIs that use a color picker to modify this input you will lose // values over one upon modification. This matches how GLTF handles it though currently, and I // think this also is an issue there as well @@ -1243,7 +1889,58 @@ _mapAutodeskStandardMaterial(const FbxSurfaceMaterial* fbxMaterial, usdMaterial.opacity.colorspace = AdobeTokens->raw; } - return true; + return true; +} + +bool +_processHardwareShaderMaterialOpenPbr(const FbxSurfaceMaterial* fbxMaterial, + ImportFbxContext& ctx, + const std::unordered_map& textures, + OpenPbrMaterial& usdMaterial, + InputTranslator& inputTranslator) +{ + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Attempting hardware shader material processing for '%s'\n", + fbxMaterial->GetName()); + + // Determine colorspace based on originalColorSpace option + const TfToken& colorPropertySpace = + (ctx.originalColorSpace == AdobeTokens->sRGB) ? AdobeTokens->sRGB : AdobeTokens->raw; + + bool foundAnyProperties = false; + + FbxProperty prop = fbxMaterial->GetFirstProperty(); + int propertyIndex = 0; + + while (prop.IsValid()) { + auto propType = prop.GetPropertyDataType(); + + // Check for ColorAndAlpha properties (typical for 3ds Max materials) + if (propType.GetType() == eFbxDouble4) { + auto typedProperty = static_cast>(prop); + FbxDouble4 colorWithAlpha = typedProperty.Get(); + GfVec3f colorValue(colorWithAlpha[0], colorWithAlpha[1], colorWithAlpha[2]); + + // (3ds Max Physical Material stores base_color as the first ColorAndAlpha property) + if (colorValue != GfVec3f(0, 0, 0) && + !usdMaterial.base_color.value.IsHolding()) { + usdMaterial.base_color.value = colorValue; + usdMaterial.base_color.colorspace = colorPropertySpace; + TF_DEBUG_MSG(FILE_FORMAT_FBX, + " Found color at property index %d: (%f, %f, %f)\n", + propertyIndex, + colorValue[0], + colorValue[1], + colorValue[2]); + foundAnyProperties = true; + } + } + + prop = fbxMaterial->GetNextProperty(prop); + propertyIndex++; + } + + return foundAnyProperties; } bool @@ -1297,6 +1994,231 @@ _processHardwareShaderMaterial(const FbxSurfaceMaterial* fbxMaterial, return foundAnyProperties; } +// Resolve a material property by one of several candidate names. 3ds Max Physical Materials +// (exported as a StandardSSL hardware shader) expose their parameters as compound child properties +// whose GetName() is empty; the meaningful identifier is the hierarchical name +// ("3dsMax|Parameters|roughness"). FbxSurfaceMaterialUtils::GetProperty only matches the flat +// name, so fall back to scanning every property and comparing both the hierarchical and leaf name. +static FbxProperty +_findMaterialProperty(const FbxSurfaceMaterial* material, const std::string& name) +{ + FbxProperty direct = FbxSurfaceMaterialUtils::GetProperty(name.c_str(), material); + if (direct.IsValid()) { + return direct; + } + for (FbxProperty prop = material->GetFirstProperty(); prop.IsValid(); + prop = material->GetNextProperty(prop)) { + if (name == prop.GetHierarchicalName().Buffer() || name == prop.GetName().Buffer()) { + return prop; + } + } + return FbxProperty(); +} + +// Fallback processor for materials with unknown ShadingModel that fail Lambert/Phong casting. +// This uses property-based detection to extract common material properties regardless of +// FBX material type classification. +// Returns true if the material was successfully processed as a property-based material +bool +_processUnknownShadingModelOpenPbr(const FbxSurfaceMaterial* fbxMaterial, + ImportFbxContext& ctx, + const std::unordered_map& textures, + OpenPbrMaterial& usdMaterial, + InputTranslator& inputTranslator) +{ + TF_DEBUG_MSG( + FILE_FORMAT_FBX, + "Processing material '%s' with unknown ShadingModel using property-based approach\n", + fbxMaterial->GetName()); + + // Determine colorspace based on originalColorSpace option + const TfToken& colorPropertySpace = + (ctx.originalColorSpace == AdobeTokens->sRGB) ? AdobeTokens->sRGB : AdobeTokens->raw; + + bool foundAnyProperties = false; + + // Helper to safely extract color properties with multiple naming conventions + auto extractColorProperty = [&](const std::vector& names, + Input& targetInput) -> bool { + for (const std::string& propName : names) { + auto property = _findMaterialProperty(fbxMaterial, propName); + if (property.IsValid()) { + // Check for both FbxDouble3DT and ColorRGB types (more flexible) + auto propType = property.GetPropertyDataType(); + TF_DEBUG_MSG(FILE_FORMAT_FBX, + " Checking property '%s' (type: %s)\n", + propName.c_str(), + propType.GetName()); + + // Check if property is compatible with FbxDouble3 or FbxDouble4 (ColorAndAlpha) + if (propType.GetType() == eFbxDouble3) { + auto typedProperty = static_cast>(property); + GfVec3f colorValue = readPropValue(typedProperty); + if (colorValue != GfVec3f(0, 0, 0)) { // Skip if all zeros + targetInput.value = colorValue; + targetInput.colorspace = colorPropertySpace; + TF_DEBUG_MSG(FILE_FORMAT_FBX, + " Found color property '%s': (%f, %f, %f)\n", + propName.c_str(), + colorValue[0], + colorValue[1], + colorValue[2]); + return true; + } + } else if (propType.GetType() == eFbxDouble4) { + // Handle ColorAndAlpha (FbxDouble4) - extract RGB, ignore alpha + auto typedProperty = static_cast>(property); + FbxDouble4 colorWithAlpha = typedProperty.Get(); + GfVec3f colorValue(colorWithAlpha[0], colorWithAlpha[1], colorWithAlpha[2]); + if (colorValue != GfVec3f(0, 0, 0)) { // Skip if all zeros + targetInput.value = colorValue; + targetInput.colorspace = colorPropertySpace; + TF_DEBUG_MSG( + FILE_FORMAT_FBX, + " Found ColorAndAlpha property '%s': (%f, %f, %f, alpha=%f)\n", + propName.c_str(), + colorValue[0], + colorValue[1], + colorValue[2], + colorWithAlpha[3]); + return true; + } + } else { + TF_DEBUG_MSG(FILE_FORMAT_FBX, + " Property '%s' has incompatible type '%s', expected FbxDouble3 " + "or FbxDouble4\n", + propName.c_str(), + propType.GetName()); + } + } + } + return false; + }; + + // Helper to safely extract scalar properties with multiple naming conventions + auto extractScalarProperty = [&](const std::vector& names, + Input& targetInput) -> bool { + for (const std::string& propName : names) { + auto property = _findMaterialProperty(fbxMaterial, propName); + if (!property.IsValid()) { + continue; + } + // Accept both Double and Float scalars; 3ds Max stores roughness/metalness as Float. + const EFbxType propScalarType = property.GetPropertyDataType().GetType(); + double scalarValue = 0.0; + if (propScalarType == eFbxFloat) { + scalarValue = static_cast>(property).Get(); + } else if (propScalarType == eFbxDouble) { + scalarValue = static_cast>(property).Get(); + } else { + continue; + } + // A hierarchical DCC parameter (e.g. "3dsMax|Parameters|roughness") is explicitly + // authored by the application, so keep its value even when it is zero, a smooth + // dielectric authored as roughness=0 would otherwise be dropped. For the legacy flat + // candidate names keep the >0 guard so unset defaults aren't written. + const bool isDccAuthored = propName.find('|') != std::string::npos; + if (scalarValue > 0.0 || isDccAuthored) { + targetInput.value = static_cast(scalarValue); + targetInput.colorspace = AdobeTokens->raw; + TF_DEBUG_MSG(FILE_FORMAT_FBX, + " Found scalar property '%s': %f\n", + propName.c_str(), + scalarValue); + return true; + } + } + return false; + }; + + // Debug: List all properties available on this material + if (TfDebug::IsEnabled(FILE_FORMAT_FBX)) { + TF_DEBUG_MSG( + FILE_FORMAT_FBX, "Debugging properties for material '%s':\n", fbxMaterial->GetName()); + FbxProperty prop = fbxMaterial->GetFirstProperty(); + int propertyCount = 0; + while (prop.IsValid()) { + const char* propName = prop.GetName(); + auto propType = prop.GetPropertyDataType(); + TF_DEBUG_MSG(FILE_FORMAT_FBX, + " Property[%d]: name='%s' hier='%s' (type: %s)\n", + propertyCount++, + propName, + prop.GetHierarchicalName().Buffer(), + propType.GetName()); + prop = fbxMaterial->GetNextProperty(prop); + } + } + + // Try to extract diffuse/base color with the exact property names from FBX ASCII analysis + const std::vector colorNames = { + // 3ds Max Physical Material properties + "3dsMax|Parameters|base_color", + // PRIMARY: Properties confirmed in FBX ASCII + "DiffuseColor", // All materials have this exact property + "AmbientColor", // Fallback color property + // SECONDARY: Common variations + "base_color", + "baseColor", + "BaseColor", + "diffuseColor", + "diffuse_color", + "Color", + "color", + "Diffuse", + "diffuse" + }; + + if (extractColorProperty(colorNames, usdMaterial.base_color)) { + foundAnyProperties = true; + } + + // Try to extract metallic with various naming conventions + const std::vector metallicNames = { "3dsMax|Parameters|metalness", + "metallic", + "Metallic", + "metalness", + "Metalness", + "metal", + "Metal" }; + + if (extractScalarProperty(metallicNames, usdMaterial.base_metalness)) { + foundAnyProperties = true; + } + + // Try to extract roughness with various naming conventions + const std::vector roughnessNames = { + "3dsMax|Parameters|roughness", "roughness", "Roughness", "specular_roughness", + "SpecularRoughness", "surface_roughness", "SurfaceRoughness" + }; + + if (extractScalarProperty(roughnessNames, usdMaterial.specular_roughness)) { + foundAnyProperties = true; + } + + // Try to extract emissive color with various naming conventions + const std::vector emissiveNames = { "emissive", "Emissive", + "emissive_color", "EmissiveColor", + "emission", "Emission", + "emission_color", "EmissionColor" }; + + if (extractColorProperty(emissiveNames, usdMaterial.emission_color)) { + foundAnyProperties = true; + } + + if (foundAnyProperties) { + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Successfully processed material '%s' using property-based approach\n", + fbxMaterial->GetName()); + return true; + } + + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "No recognizable properties found for material '%s'\n", + fbxMaterial->GetName()); + return false; +} + // Fallback processor for materials with unknown ShadingModel that fail Lambert/Phong casting. // This uses property-based detection to extract common material properties regardless of // FBX material type classification. @@ -1323,7 +2245,7 @@ _processUnknownShadingModel(const FbxSurfaceMaterial* fbxMaterial, auto extractColorProperty = [&](const std::vector& names, Input& targetInput) -> bool { for (const std::string& propName : names) { - auto property = FbxSurfaceMaterialUtils::GetProperty(propName.c_str(), fbxMaterial); + auto property = _findMaterialProperty(fbxMaterial, propName); if (property.IsValid()) { // Check for both FbxDouble3DT and ColorRGB types (more flexible) auto propType = property.GetPropertyDataType(); @@ -1381,18 +2303,33 @@ _processUnknownShadingModel(const FbxSurfaceMaterial* fbxMaterial, auto extractScalarProperty = [&](const std::vector& names, Input& targetInput) -> bool { for (const std::string& propName : names) { - auto property = FbxSurfaceMaterialUtils::GetProperty(propName.c_str(), fbxMaterial); - if (property.IsValid() && property.GetPropertyDataType() == FbxDoubleDT) { - double scalarValue = property.Get(); - if (scalarValue > 0.0) { // Skip if zero or negative - targetInput.value = static_cast(scalarValue); - targetInput.colorspace = AdobeTokens->raw; - TF_DEBUG_MSG(FILE_FORMAT_FBX, - " Found scalar property '%s': %f\n", - propName.c_str(), - scalarValue); - return true; - } + auto property = _findMaterialProperty(fbxMaterial, propName); + if (!property.IsValid()) { + continue; + } + // Accept both Double and Float scalars; 3ds Max stores roughness/metalness as Float. + const EFbxType propScalarType = property.GetPropertyDataType().GetType(); + double scalarValue = 0.0; + if (propScalarType == eFbxFloat) { + scalarValue = static_cast>(property).Get(); + } else if (propScalarType == eFbxDouble) { + scalarValue = static_cast>(property).Get(); + } else { + continue; + } + // A hierarchical DCC parameter (e.g. "3dsMax|Parameters|roughness") is explicitly + // authored by the application, so keep its value even when it is zero, a smooth + // dielectric authored as roughness=0 would otherwise be dropped. For the legacy flat + // candidate names keep the >0 guard so unset defaults aren't written. + const bool isDccAuthored = propName.find('|') != std::string::npos; + if (scalarValue > 0.0 || isDccAuthored) { + targetInput.value = static_cast(scalarValue); + targetInput.colorspace = AdobeTokens->raw; + TF_DEBUG_MSG(FILE_FORMAT_FBX, + " Found scalar property '%s': %f\n", + propName.c_str(), + scalarValue); + return true; } } return false; @@ -1408,9 +2345,10 @@ _processUnknownShadingModel(const FbxSurfaceMaterial* fbxMaterial, const char* propName = prop.GetName(); auto propType = prop.GetPropertyDataType(); TF_DEBUG_MSG(FILE_FORMAT_FBX, - " Property[%d]: '%s' (type: %s)\n", + " Property[%d]: name='%s' hier='%s' (type: %s)\n", propertyCount++, propName, + prop.GetHierarchicalName().Buffer(), propType.GetName()); prop = fbxMaterial->GetNextProperty(prop); } @@ -1581,8 +2519,19 @@ importFbxMaterials(ImportFbxContext& ctx) std::error_code error_code; if (!std::filesystem::exists(absFilePath, error_code)) { - TF_WARN("FBX image \"%s\" not found", absFilePath.u8string().c_str()); - continue; + // FBX SDK quirk: GetRelativeFileName() can return an absolute-looking + // path (e.g. "/foo.png" on POSIX) for textures that are actually siblings + // of the FBX file. If the literal path doesn't exist, fall back to + // resolving the basename next to the FBX before giving up. This also + // lets us recover when the FBX was authored on a different machine and + // the original absolute path is no longer valid. + std::filesystem::path siblingPath = parentPath / absFilePath.filename(); + if (std::filesystem::exists(siblingPath, error_code)) { + absFilePath = siblingPath.make_preferred(); + } else { + TF_WARN("FBX image \"%s\" not found", absFilePath.u8string().c_str()); + continue; + } } } else { // We then convert the path to use the native OS separator before combining it with @@ -1638,25 +2587,52 @@ importFbxMaterials(ImportFbxContext& ctx) InputTranslator inputTranslator(ctx.options->importImages, images, DEBUG_TAG); size_t materialsCount = ctx.scene->GetSrcObjectCount(); - ctx.usd->materials.resize(materialsCount); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + if (useOpenPbr) { + ctx.usd->openPbrMaterials.resize(materialsCount); + } else { + ctx.usd->materials.resize(materialsCount); + } TF_DEBUG_MSG(FILE_FORMAT_FBX, "\tMaterials count: %lu \n", materialsCount); for (size_t i = 0; i < materialsCount; i++) { - Material& um = ctx.usd->materials[i]; FbxSurfaceMaterial* material = ctx.scene->GetSrcObject(i); ctx.materials[material] = i; // Should use GetUniqueID() instead of FbxObject* as key? - um.name = material->GetName(); - TF_DEBUG_MSG(FILE_FORMAT_FBX, "importFbx: material[%lu] { %s }\n", i, um.name.c_str()); FbxProperty lP = FbxSurfaceMaterialUtils::GetProperty(FbxSurfaceMaterial::sShadingModel, material); auto shaderModel = lP.Get().Buffer(); - TF_DEBUG_MSG(FILE_FORMAT_FBX, " Shader model: %s\n", shaderModel); - // Check for and process the autodesk standard surface representation first before we do - // anything else as this is handled as a special case - if (_mapAutodeskStandardMaterial(material, ctx, textures, um, inputTranslator)) { - // Everything was done in the above util, so we can just continue - continue; + if (useOpenPbr) { + OpenPbrMaterial& um = ctx.usd->openPbrMaterials[i]; + um.name = material->GetName(); + TF_DEBUG_MSG(FILE_FORMAT_FBX, "importFbx: material[%lu] { %s }\n", i, um.name.c_str()); + TF_DEBUG_MSG(FILE_FORMAT_FBX, " Shader model: %s\n", shaderModel); + + // Check for and process the autodesk standard surface representation first before we do + // anything else as this is handled as a special case + if (_mapAutodeskStandardMaterialOpenPbr(material, ctx, textures, um, inputTranslator)) { + // Everything was done in the above util, so we can just continue + continue; + } + + // Native OpenPBR materials authored by Maya 2026 / 3ds Max 2026. These carry the full + // OpenPBR parameter set under a vendor prefix and don't cast to Lambert/Phong, so + // handle them before falling through to the traditional and heuristic fallbacks. + if (_mapDccOpenPbrMaterialOpenPbr(material, ctx, textures, um, inputTranslator)) { + continue; + } + } else { + Material& um = ctx.usd->materials[i]; + um.name = material->GetName(); + TF_DEBUG_MSG(FILE_FORMAT_FBX, "importFbx: material[%lu] { %s }\n", i, um.name.c_str()); + TF_DEBUG_MSG(FILE_FORMAT_FBX, " Shader model: %s\n", shaderModel); + + // Check for and process the autodesk standard surface representation first before we do + // anything else as this is handled as a special case + if (_mapAutodeskStandardMaterial(material, ctx, textures, um, inputTranslator)) { + // Everything was done in the above util, so we can just continue + continue; + } } // Try traditional Lambert/Phong casting @@ -1751,54 +2727,165 @@ importFbxMaterials(ImportFbxContext& ctx) AdobeTokens->raw); } - if (ctx.options->importPhong) { - inputTranslator.translatePhong2PBR( - diffuse, specular, shininess, um.diffuseColor, um.metallic, um.roughness); - } else { - inputTranslator.translateDirect(diffuse, um.diffuseColor); - // Note, using reflectionFactor for metallic, and specularFactor for roughness, are - // very crude approximations for a Phong to PBR conversion. - inputTranslator.translateDirect(reflectionFactor, um.metallic); - inputTranslator.translateDirect(specularFactor, um.roughness); - } - - inputTranslator.translateFactor(emissive, emissiveFactor, um.emissiveColor); - - // ignore specular color if there is a specular factor texture but no specular color - if ((specular.image >= 0) || (specularFactor.image < 0)) { - inputTranslator.translateFactor(specular, specularFactor, um.specularColor); - } - - // NOTE: as commented above, we are ignoring TransparentColor values so the - // condition in the 'if' statement below should always be false, in which case - // the 'else' block will be executed. + // Convert Phong specular/shininess to PBR roughness even when importPhong is off, so + // glossiness is preserved. Lambert materials don't cast to FbxSurfacePhong, so this + // only affects materials that actually carry Phong specular/shininess data. + const bool usePhongConversion = + ctx.options->importPhong || + (phong && (!shininess.value.IsEmpty() || shininess.image >= 0)); + + if (useOpenPbr) { + OpenPbrMaterial& um = ctx.usd->openPbrMaterials[i]; + if (ctx.options->importPhong) { + // Caller explicitly requested full Phong-to-PBR: derive both metallic and + // roughness from the algorithm. + inputTranslator.translatePhong2PBR(diffuse, + specular, + shininess, + um.base_color, + um.base_metalness, + um.specular_roughness); + } else if (usePhongConversion) { + // Default Phong import treats the surface as a dielectric: base color from + // diffuse, roughness from shininess, and metalness only from an authored + // ReflectionFactor (empty/zero stays dielectric). Inferring metalness from + // specular brightness chromes painted or colored surfaces, since Maya/Max + // export a bright default specular highlight on nearly everything, and the + // resulting metals render black without an environment. importPhong=true above + // still runs the full solve for callers that explicitly want it. + inputTranslator.translateDirect(diffuse, um.base_color); + inputTranslator.translateDirect(reflectionFactor, um.base_metalness); + inputTranslator.translatePhong2Roughness( + specular, shininess, um.specular_roughness); + } else { + inputTranslator.translateDirect(diffuse, um.base_color); + if (phong) { + inputTranslator.translateDirect(reflectionFactor, um.base_metalness); + inputTranslator.translateDirect(specularFactor, um.specular_roughness); + } else { + // Lambert is fully diffuse with no glossiness or specular concept: + // author a matte, non-metallic surface and zero the dielectric specular + // lobe. Otherwise the glTF exporter falls back to its hardcoded 0.5 + // roughness default and OpenPBR's specular_weight default of 1.0 leaves a + // highlight that Lambert never has. + um.specular_roughness = Input{ VtValue(1.0f) }; + um.base_metalness = Input{ VtValue(0.0f) }; + um.specular_weight = Input{ VtValue(0.0f) }; + // Maya/FBX store the Lambert "Diffuse" scalar as DiffuseFactor; carry it + // into base_weight so the diffuse albedo isn't silently scaled up to the + // OpenPBR base_weight default of 1.0. No-ops when DiffuseFactor is absent. + inputTranslator.translateDirect(diffuseFactor, um.base_weight); + } + } + inputTranslator.translateFactor(emissive, emissiveFactor, um.emission_color); + if (!um.emission_color.isEmpty()) { + um.emission_luminance = Input{ VtValue(1000.0f) }; + } + // Ignore specular color if there is a specular factor texture but no specular + // color texture + if ((specular.image >= 0) || (specularFactor.image < 0)) { + inputTranslator.translateFactor(specular, specularFactor, um.specular_color); + } - // If there is a TransparentColor texture, we use it directly as the opacity channel - if (transparentColor.image >= 0) { - inputTranslator.translateDirect(transparentColor, um.opacity); + // NOTE: as commented above, we are ignoring TransparentColor values so the + // condition in the 'if' statement below should always be false, in which case + // the 'else' block will be executed. + if (transparentColor.image >= 0) { + // If there is a TransparentColor texture, use it directly as opacity + inputTranslator.translateDirect(transparentColor, um.geometry_opacity); + } else { + // There are FBX files where both the Opacity and TransparencyFactor + // properties are present (even though the Opacity property has been phased + // out and is not defined as a property of FbxSurfaceLambert). In some + // cases, both properties are present in the material definition and so + // it's unclear which should be used. We use the "TransparencyFactor" + // (ie 1.0) as is when both values are present and both equal 1.0. + // Otherwise, we convert TransparencyFactor to an opacity value by + // computing 1.0 - TransparencyFactor + FbxProperty opacityProp = material->FindProperty("Opacity", FbxDoubleDT, true); + FbxProperty transparencyFactorProp = + material->FindProperty("TransparencyFactor", FbxDoubleDT, true); + if (opacityProp.IsValid() && transparencyFactorProp.IsValid() && + 1.0 == opacityProp.Get() && + 1.0 == transparencyFactorProp.Get()) { + // Use the transparencyFactor as is and treat it like an opacity value + inputTranslator.translateDirect(transparencyFactor, um.geometry_opacity); + } else { + // Invert transparencyFactor and assign to usd opacity + inputTranslator.translateTransparency2Opacity(transparencyFactor, + um.geometry_opacity); + } + } + inputTranslator.translateNormals(bump, normal, um.geometry_normal); } else { - // There are FBX files where both the Opacity and TransparencyFactor properties are - // present (even though the Opacity property has been phased out and is not defined - // as a property of FbxSurfaceLambert). In some cases, both properties are present - // in the material definition and so it's unclear which should be used. We use the - // "TransparencyFactor" (ie 1.0) as is when both values are present and both - // equal 1.0. Otherwise, we convert TransparencyFactor to an opacity value by - // computing 1.0 - TransparencyFactor - FbxProperty opacityProp = material->FindProperty("Opacity", FbxDoubleDT, true); - FbxProperty transparencyFactorProp = - material->FindProperty("TransparencyFactor", FbxDoubleDT, true); - if (opacityProp.IsValid() && transparencyFactorProp.IsValid() && - 1.0 == opacityProp.Get() && - 1.0 == transparencyFactorProp.Get()) { - // Use the transparencyFactor as is and treat it like an opacity value - inputTranslator.translateDirect(transparencyFactor, um.opacity); + Material& um = ctx.usd->materials[i]; + if (ctx.options->importPhong) { + // Caller explicitly requested full Phong-to-PBR: derive both metallic and + // roughness from the algorithm. + inputTranslator.translatePhong2PBR( + diffuse, specular, shininess, um.diffuseColor, um.metallic, um.roughness); + } else if (usePhongConversion) { + // Default Phong import treats the surface as a dielectric: base color from + // diffuse, roughness from shininess, and metalness only from an authored + // ReflectionFactor (empty/zero stays dielectric). Inferring metalness from + // specular brightness chromes painted or colored surfaces, since Maya/Max + // export a bright default specular highlight on nearly everything, and the + // resulting metals render black without an environment. importPhong=true above + // still runs the full solve for callers that explicitly want it. + inputTranslator.translateDirect(diffuse, um.diffuseColor); + inputTranslator.translateDirect(reflectionFactor, um.metallic); + inputTranslator.translatePhong2Roughness(specular, shininess, um.roughness); + } else { + inputTranslator.translateDirect(diffuse, um.diffuseColor); + if (phong) { + inputTranslator.translateDirect(reflectionFactor, um.metallic); + inputTranslator.translateDirect(specularFactor, um.roughness); + } else { + // Lambert is fully diffuse with no glossiness concept: author a matte, + // non-metallic surface. Otherwise the glTF exporter falls back to its + // hardcoded 0.5 roughness default and the surface looks glossy. + um.roughness = Input{ VtValue(1.0f) }; + um.metallic = Input{ VtValue(0.0f) }; + } + } + inputTranslator.translateFactor(emissive, emissiveFactor, um.emissiveColor); + // Ignore specular color if there is a specular factor texture but no specular + // color texture + if ((specular.image >= 0) || (specularFactor.image < 0)) { + inputTranslator.translateFactor(specular, specularFactor, um.specularColor); + } + + // NOTE: as commented above, we are ignoring TransparentColor values so the + // condition in the 'if' statement below should always be false, in which case + // the 'else' block will be executed. + if (transparentColor.image >= 0) { + // If there is a TransparentColor texture, use it directly as opacity + inputTranslator.translateDirect(transparentColor, um.opacity); } else { - // invert transparencyFactor and assign to usd opacity - inputTranslator.translateTransparency2Opacity(transparencyFactor, um.opacity); + // There are FBX files where both the Opacity and TransparencyFactor + // properties are present (even though the Opacity property has been phased + // out and is not defined as a property of FbxSurfaceLambert). In some + // cases, both properties are present in the material definition and so + // it's unclear which should be used. We use the "TransparencyFactor" + // (ie 1.0) as is when both values are present and both equal 1.0. + // Otherwise, we convert TransparencyFactor to an opacity value by + // computing 1.0 - TransparencyFactor + FbxProperty opacityProp = material->FindProperty("Opacity", FbxDoubleDT, true); + FbxProperty transparencyFactorProp = + material->FindProperty("TransparencyFactor", FbxDoubleDT, true); + if (opacityProp.IsValid() && transparencyFactorProp.IsValid() && + 1.0 == opacityProp.Get() && + 1.0 == transparencyFactorProp.Get()) { + // Use the transparencyFactor as is and treat it like an opacity value + inputTranslator.translateDirect(transparencyFactor, um.opacity); + } else { + // Invert transparencyFactor and assign to usd opacity + inputTranslator.translateTransparency2Opacity(transparencyFactor, + um.opacity); + } } + inputTranslator.translateNormals(bump, normal, um.normal); } - - inputTranslator.translateNormals(bump, normal, um.normal); } else { // Elegant fallback: Try property-based processing for materials that failed // Lambert/Phong casting @@ -1819,25 +2906,58 @@ importFbxMaterials(ImportFbxContext& ctx) TF_DEBUG_MSG( FILE_FORMAT_FBX, " RenderAPIVersion: %s\n", imp->RenderAPIVersion.Get().Buffer()); - // Try to extract properties from hardware shader - if (_processHardwareShaderMaterial(material, ctx, textures, um, inputTranslator)) { + // Extract properties from the hardware shader. The hardware-shader processor only + // recovers a base color (the first non-zero ColorAndAlpha property), so also run + // the property-based extractor, which reads named scalars such as + // "3dsMax|Parameters|roughness" / "metalness". A 3ds Max Physical Material exported + // as a StandardSSL hardware shader otherwise loses its roughness and metalness. + // The property-based extractor runs first so its named base color wins; the + // hardware-shader heuristic then only fills the color in if nothing named was + // found. + bool extracted = false; + if (useOpenPbr) { + OpenPbrMaterial& um = ctx.usd->openPbrMaterials[i]; + extracted |= _processUnknownShadingModelOpenPbr( + material, ctx, textures, um, inputTranslator); + extracted |= _processHardwareShaderMaterialOpenPbr( + material, ctx, textures, um, inputTranslator); + } else { + Material& um = ctx.usd->materials[i]; + extracted |= + _processUnknownShadingModel(material, ctx, textures, um, inputTranslator); + extracted |= + _processHardwareShaderMaterial(material, ctx, textures, um, inputTranslator); + } + + if (extracted) { TF_DEBUG_MSG(FILE_FORMAT_FBX, "Successfully processed hardware shader '%s'\n", material->GetName()); - continue; + } else { + TF_WARN("Hardware shader '%s' detected but no properties could be extracted\n", + material->GetName()); } - - TF_WARN("Hardware shader '%s' detected but no properties could be extracted\n", - material->GetName()); continue; } // Try standard property-based fallback for non-hardware shader materials - if (_processUnknownShadingModel(material, ctx, textures, um, inputTranslator)) { - TF_DEBUG_MSG(FILE_FORMAT_FBX, - "Successfully processed '%s' using property-based fallback\n", - material->GetName()); - continue; + if (useOpenPbr) { + OpenPbrMaterial& um = ctx.usd->openPbrMaterials[i]; + if (_processUnknownShadingModelOpenPbr( + material, ctx, textures, um, inputTranslator)) { + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Successfully processed '%s' using property-based fallback\n", + material->GetName()); + continue; + } + } else { + Material& um = ctx.usd->materials[i]; + if (_processUnknownShadingModel(material, ctx, textures, um, inputTranslator)) { + TF_DEBUG_MSG(FILE_FORMAT_FBX, + "Successfully processed '%s' using property-based fallback\n", + material->GetName()); + continue; + } } // If we get here, the material couldn't be processed by any method @@ -2311,7 +3431,8 @@ importFbxSkeleton(ImportFbxContext& ctx, const ImportedFbxSkeleton& importedSkel if (fbxNode->LclRotation.IsAnimated() || fbxNode->LclTranslation.IsAnimated() || fbxNode->LclScaling.IsAnimated()) { - for (int animationStackIndex = 0; animationStackIndex < ctx.animationStacks.size(); + for (int animationStackIndex = 0; + animationStackIndex < static_cast(ctx.animationStacks.size()); animationStackIndex++) { const ImportedFbxStack& fbxStack = ctx.animationStacks[animationStackIndex]; std::set& frames = framesInEachStack[animationStackIndex]; @@ -2410,7 +3531,8 @@ importFbxSkeleton(ImportFbxContext& ctx, const ImportedFbxSkeleton& importedSkel skeleton.animatedJoints.push_back(pair.second); } - for (int animationStackIndex = 0; animationStackIndex < framesInEachStack.size(); + for (int animationStackIndex = 0; + animationStackIndex < static_cast(framesInEachStack.size()); animationStackIndex++) { AnimationTrack& track = ctx.usd->animationTracks[animationStackIndex]; std::set& frames = framesInEachStack[animationStackIndex]; @@ -2689,7 +3811,9 @@ importFbxNodes(ImportFbxContext& ctx, FbxNode* fbxNode, int parent) importFbxCamera(ctx, attribute, parentIndex); break; case FbxNodeAttribute::eLight: - importFbxLight(ctx, attribute, parentIndex); + if (ctx.options->importLights) { + importFbxLight(ctx, attribute, parentIndex); + } break; case FbxNodeAttribute::eLODGroup: importFbxLOD(ctx, attribute, parentIndex); @@ -2733,10 +3857,10 @@ importFbxNodeHierarchy(ImportFbxContext& ctx) } } -// Before converting meshes from Fbx to USD, we first triangulate -// any meshes that have edge information which defines a specific -// triangulation (ie. the splitting of quads). We don't pre-triangulate -// meshes that don't have edge information. +// Before converting meshes from Fbx to USD, we pre-triangulate meshes whose FBX +// edge information defines a specific triangulation (e.g. splitting quads), plus +// meshes containing an untriangulated n-gon (see below). Meshes with neither are +// left as authored. void triangulateMeshes(ImportFbxContext& ctx) { @@ -2748,21 +3872,41 @@ triangulateMeshes(ImportFbxContext& ctx) std::vector meshes; meshes.reserve(meshCount); - // Collect meshes with non-zero edge counts. We will triangle only those - // as the edge information is relevent to the triangulation. + // Collect meshes with non-zero edge counts, plus any mesh that still has an + // untriangulated n-gon (>4 sided polygon). Edge count alone isn't a reliable + // signal: some DCCs export a flat, hole-free glyph cap (eg. text extrusion caps + // for letters like "s"/"c") as a single large n-gon with zero recorded edges, + // since no edge-visibility data is needed for one flat face. Left untriangulated, + // that concave n-gon gets naively fan-triangulated by downstream consumers, + // producing garbled geometry. // We can't triangulate in this loop because triangulation affects the // ordering of meshes. for (size_t i = 0; i < meshCount; ++i) { FbxMesh* mesh = ctx.scene->GetSrcObject(i); - size_t polyCount = mesh->GetPolygonCount(); - size_t edgeCount = mesh->GetMeshEdgeCount(); + int polyCount = mesh->GetPolygonCount(); + int edgeCount = mesh->GetMeshEdgeCount(); + + // Only scan for n-gons when edge information hasn't already selected the + // mesh: if edgeCount > 0 we triangulate regardless, so the scan is wasted. + bool hasNgon = false; + if (edgeCount <= 0) { + for (int p = 0; p < polyCount; ++p) { + if (mesh->GetPolygonSize(p) > 4) { + hasNgon = true; + break; + } + } + } + TF_DEBUG_MSG(FILE_FORMAT_FBX, - "importFbx: mesh[%lu]=%s polycount=%lu edgecount=%lu\n", + "importFbx: mesh[%lu]=%s polycount=%d edgecount=%d hasNgon=%d\n", i, mesh->GetName(), polyCount, - edgeCount); - if (edgeCount > 0) { + edgeCount, + hasNgon); + + if (edgeCount > 0 || hasNgon) { meshes.push_back(mesh); } } @@ -2772,9 +3916,19 @@ triangulateMeshes(ImportFbxContext& ctx) // triangulate each mesh for (auto mesh : meshes) { - // We use the legacy triangulation algorithm because crashes have been occuring - // when using the newer algorithm. - conv.Triangulate(mesh, /* pReplace = */ true, /* pLegacy = */ true); + // Triangulate with pReplace=false, then destroy the original. + // + // pReplace=true crashes inside FBX SDK 2020.3.9 FbxGeometryConverter::Triangulate + // (DisconnectDstObject -> FbxPropertyHandle::GetPageDataPtr null deref) for + // skinned meshes - both the legacy and the new triangulation paths go through + // the same crashy replacement bookkeeping. pReplace=false adds a new triangulated + // mesh as the node's default attribute and leaves the original alone, which we + // then destroy ourselves. The new mesh has its own preserved skin/shape channels. + FbxNodeAttribute* tri = + conv.Triangulate(mesh, /* pReplace = */ false, /* pLegacy = */ true); + if (tri && tri != mesh) { + mesh->Destroy(); + } } } } diff --git a/fbx/src/fbxImport.h b/fbx/src/fbxImport.h index 440f3549..4f96d585 100644 --- a/fbx/src/fbxImport.h +++ b/fbx/src/fbxImport.h @@ -20,6 +20,7 @@ struct ImportFbxOptions bool importGeometry = true; bool importMaterials = true; bool importImages = true; + bool importLights = true; bool importPhong = false; bool importAnimationStacks = false; bool triangulateMeshes = true; diff --git a/fbx/src/fileFormat.cpp b/fbx/src/fileFormat.cpp index 41aa89c4..0f4c84f5 100644 --- a/fbx/src/fileFormat.cpp +++ b/fbx/src/fileFormat.cpp @@ -30,6 +30,7 @@ PXR_NAMESPACE_OPEN_SCOPE static std::mutex mutex; const TfToken UsdFbxFileFormat::animationStacksToken("fbxAnimationStacks", TfToken::Immortal); const TfToken UsdFbxFileFormat::assetsPathToken("fbxAssetsPath", TfToken::Immortal); +const TfToken UsdFbxFileFormat::importLightsToken("importLights", TfToken::Immortal); const TfToken UsdFbxFileFormat::originalColorSpaceToken("fbxOriginalColorSpace", TfToken::Immortal); const TfToken UsdFbxFileFormat::phongToken("fbxPhong", TfToken::Immortal); const TfToken UsdFbxFileFormat::triangulateMeshesToken("triangulateMeshes", TfToken::Immortal); @@ -68,6 +69,7 @@ UsdFbxFileFormat::InitData(const FileFormatArguments& args) const argWarnDeprecatedArg(args, assetsPathToken.GetString(), DEBUG_TAG); argReadBool(args, animationStacksToken.GetString(), pd->animationStacks, DEBUG_TAG); + argReadBool(args, importLightsToken.GetString(), pd->importLights, DEBUG_TAG); argReadBool(args, phongToken.GetString(), pd->phong, DEBUG_TAG); argReadBool(args, triangulateMeshesToken.GetString(), pd->triangulateMeshes, DEBUG_TAG); argReadString(args, originalColorSpaceToken.GetString(), pd->originalColorSpace, DEBUG_TAG); @@ -81,6 +83,7 @@ UsdFbxFileFormat::ComposeFieldsForFileFormatArguments(const std::string& assetPa { argComposeBool(context, args, animationStacksToken, DEBUG_TAG); argComposeString(context, args, assetsPathToken, DEBUG_TAG); + argComposeBool(context, args, importLightsToken, DEBUG_TAG); argComposeBool(context, args, phongToken, DEBUG_TAG); argComposeBool(context, args, triangulateMeshesToken, DEBUG_TAG); argComposeString(context, args, originalColorSpaceToken, DEBUG_TAG); @@ -116,6 +119,7 @@ UsdFbxFileFormat::Read(SdfLayer* layer, const std::string& resolvedPath, bool me options.importGeometry = true; options.importMaterials = true; options.importImages = !data->assetsPath.empty(); + options.importLights = data->importLights; options.importPhong = data->phong; options.originalColorSpace = data->originalColorSpace; options.triangulateMeshes = data->triangulateMeshes; diff --git a/fbx/src/fileFormat.h b/fbx/src/fileFormat.h index 9d5450b5..febca060 100644 --- a/fbx/src/fileFormat.h +++ b/fbx/src/fileFormat.h @@ -48,6 +48,7 @@ class FbxData : public FileFormatDataBase { public: bool animationStacks = false; + bool importLights = true; bool phong = false; bool triangulateMeshes = true; TfToken originalColorSpace; @@ -101,6 +102,7 @@ class USDFBX_API UsdFbxFileFormat protected: static const TfToken animationStacksToken; static const TfToken assetsPathToken; + static const TfToken importLightsToken; static const TfToken originalColorSpaceToken; static const TfToken phongToken; static const TfToken triangulateMeshesToken; diff --git a/fbx/src/plugInfo.json.in b/fbx/src/plugInfo.json.in index e7952d0d..0638beaf 100644 --- a/fbx/src/plugInfo.json.in +++ b/fbx/src/plugInfo.json.in @@ -49,7 +49,7 @@ } } }, - "LibraryPath": "${PLUG_INFO_LIBRARY_PATH}", + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", "Name": "usdFbx_plugin", "ResourcePath": "resources", "Root": "..", diff --git a/fbx/tests/CMakeLists.txt b/fbx/tests/CMakeLists.txt index fb30b495..a5fb22ce 100644 --- a/fbx/tests/CMakeLists.txt +++ b/fbx/tests/CMakeLists.txt @@ -11,6 +11,8 @@ PRIVATE GTest::gtest GTest::gtest_main fbxsdk::fbxsdk + fileformatUtilsTest + gtestCommon ) gtest_add_tests(TARGET fbxSanityTests AUTO) diff --git a/fbx/tests/fbx-usd/uppercase.usd b/fbx/tests/fbx-usd/uppercase.usd new file mode 100644 index 00000000..fe464509 Binary files /dev/null and b/fbx/tests/fbx-usd/uppercase.usd differ diff --git a/fbx/tests/sanityTests.cpp b/fbx/tests/sanityTests.cpp index 52875b58..14d3e9b9 100644 --- a/fbx/tests/sanityTests.cpp +++ b/fbx/tests/sanityTests.cpp @@ -9,6 +9,8 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +#include +#include #include #include #include @@ -26,7 +28,7 @@ TEST(Sanity, LoadCube) PXR_NAMESPACE_USING_DIRECTIVE // Load an FBX - UsdStageRefPtr stage = UsdStage::Open("SanityCube.fbx"); + UsdStageRefPtr stage = openAssetStage(assetDir + "SanityCube.fbx"); ASSERT_TRUE(stage); UsdPrim mesh = stage->GetPrimAtPath(SdfPath("/SanityCube/Cube")); ASSERT_TRUE(mesh); @@ -36,7 +38,7 @@ TEST(Sanity, ExportCube) { PXR_NAMESPACE_USING_DIRECTIVE - FbxScene* scene = getFbxSceneFromUsd("cube.usd"); + FbxScene* scene = getFbxSceneFromUsd(assetDir + "cube.usd"); ASSERT_TRUE(scene); // Start the recursive traversal from the root node diff --git a/fbx/tests/util.cpp b/fbx/tests/util.cpp index 0c41ad9b..96122313 100644 --- a/fbx/tests/util.cpp +++ b/fbx/tests/util.cpp @@ -12,6 +12,7 @@ governing permissions and limitations under the License. #include +#include #include #include @@ -60,7 +61,6 @@ FbxLoaderSingleton::loadScene(std::string filename) FbxScene* scene = FbxScene::Create(manager, "root"); bool onlyMaterials = false; - bool importImages = true; // TODO: use this when adding callback below ios->SetBoolProp(IMP_FBX_MATERIAL, true); ios->SetBoolProp(IMP_FBX_TEXTURE, true); ios->SetBoolProp(IMP_FBX_ANIMATION, !onlyMaterials); @@ -137,7 +137,7 @@ getFbxSceneFromUsd(const std::filesystem::path& usdFilepath, std::filesystem::path fbxPath = tempDir / fbxFilename; // Convert USD to FBX - UsdStageRefPtr stage = UsdStage::Open(usdFilepath.string()); + UsdStageRefPtr stage = openAssetStage(usdFilepath.string()); if (!stage) { TF_WARN("Failed to open USD stage"); return nullptr; diff --git a/gltf/CMakeLists.txt b/gltf/CMakeLists.txt index d1127491..e5cb4bda 100644 --- a/gltf/CMakeLists.txt +++ b/gltf/CMakeLists.txt @@ -1,5 +1,5 @@ option(USD_FILEFORMATS_ENABLE_ASSET_TESTS "Build the more in depth unit tests using downloaded assets." OFF) -option(USDGLTF_ENABLE_INSTALL "Enable installation of plugin artifacts" ON) +cmake_dependent_option(USDGLTF_ENABLE_INSTALL "Enable installation of plugin artifacts" ON "USD_FILEFORMATS_ENABLE_INSTALL" OFF) option(USD_FILEFORMATS_ENABLE_DRACO "Enable reading of draco meshes in glTF" ON) set(THREADS_PREFER_PTHREAD_FLAG ON) @@ -19,6 +19,10 @@ endif() add_subdirectory(src) + +# Pass this list from the src/CMakeLists.txt to the parent scope +set(GLTF_EXT_LIST ${GLTF_EXT_LIST} PARENT_SCOPE) + if(USD_FILEFORMATS_BUILD_TESTS) add_subdirectory(tests) endif() @@ -29,3 +33,4 @@ set(CPACK_INSTALL_CMAKE_PROJECTS "src;usdGltf;ALL;/") include(CPack) +fileformats_register_plugin("usdGltf") diff --git a/gltf/README.md b/gltf/README.md index 075ba63c..ef84299e 100644 --- a/gltf/README.md +++ b/gltf/README.md @@ -67,14 +67,18 @@ During material import, the ASM shading model is used as an intermediate transpo | KHR_lights_punctual |✅| | KHR_materials_anisotropy |✅| | KHR_materials_clearcoat |✅| -| KHR_materials_dispersion |❌| +| KHR_materials_coat |✅| +| KHR_materials_diffuse_roughness |✅| +| KHR_materials_diffuse_transmission |✅| +| KHR_materials_dispersion |✅| | KHR_materials_emissive_strength |✅| +| KHR_materials_fuzz |✅| | KHR_materials_ior |✅| -| KHR_materials_iridescence |❌| +| KHR_materials_iridescence |✅| | KHR_materials_sheen |✅| | KHR_materials_specular |✅| | KHR_materials_transmission | -| KHR_materials_unlit |❌| +| KHR_materials_unlit |✅|Imported as emissive in USD with metadata to ensure roundtripping exports an unlit, not emissive, material| | KHR_materials_variants |❌| | KHR_materials_volume |✅| | KHR_materials_volume_scatter |✅| @@ -88,7 +92,6 @@ During material import, the ASM shading model is used as an intermediate transpo | ADOBE_materials_clearcoat_specular |✅| | ADOBE_materials_clearcoat_tint |✅| | EXT_materials_clearcoat_color |✅| -| KHR_materials_coat |✅| | KHR_materials_pbrSpecularGlossiness |✅| Anisotropy @@ -161,17 +164,21 @@ Mesh bounding box exported as min and max accessor bounds in glTF. * `gltfAssetsPath`: Deprecated in favor of `assetsPath`. -* `writeUsdPreviewSurface`: Generate a UsdPreviewSurface based network for each material. Default is `true` +* `importLights`: Controls whether to import lights or not. Default is `true` + + When this is disabled, gltf lights will be ignored and not converted to USD on import. + +* `writeUsdPreviewSurface`: Generate a UsdPreviewSurface based network for each material. Default is `true` (deprecated) UsdPreviewSurface and its associated nodes are a universally understood USD material description and all application should support them. The PBR capabilities are limited. -* `writeASM`: Generate a ASM (Adobe Standard Material) based network for each material. Default is `true` +* `writeASM`: Generate a ASM (Adobe Standard Material) based network for each material. Default is `false` (deprecated) ASM is a standard supported by many Adobe applications with richer support for PBR capabilities. It will be superseded by OpenPBR in the near future. -* `writeOpenPBR`: Generate a OpenPBR based material network for each material. Default is `false` +* `writeOpenPBR`: Generate a OpenPBR based material network for each material. Default is `true` OpenPBR is a new industry standard that will have wide spread support, but is still in its infancy. The material network uses `MaterialX` nodes to express individual operations and has an `OpenPBR` surface, diff --git a/gltf/src/CMakeLists.txt b/gltf/src/CMakeLists.txt index 4805da5a..7650870a 100644 --- a/gltf/src/CMakeLists.txt +++ b/gltf/src/CMakeLists.txt @@ -1,4 +1,7 @@ add_library(usdGltf SHARED) + +set(GLTF_EXT_LIST "gltf;GLTF;glTF;glb;GLB;glB" PARENT_SCOPE) + usd_plugin_compile_config(usdGltf) target_compile_definitions(usdGltf PRIVATE USDGLTF_EXPORTS) @@ -11,9 +14,12 @@ PRIVATE "fileFormat.cpp" "gltf.h" "gltf.cpp" - "tinygltf.cpp" "gltfAnisotropy.h" "gltfAnisotropy.cpp" + "gltfAnisotropyASM.h" + "gltfAnisotropyASM.cpp" + "gltfAnisotropyOpenPBR.h" + "gltfAnisotropyOpenPBR.cpp" "gltfExport.h" "gltfExport.cpp" "gltfImport.h" @@ -25,6 +31,23 @@ PRIVATE "importGltfContext.h" ) +# tinygltf.cpp defines TINYGLTF_IMPLEMENTATION and compiles the full tinygltf +# implementation as a single TU (the header-only library pattern). Only do this +# when tinygltf::tinygltf is an INTERFACE (header-only) target. When it is a +# compiled STATIC library, the lib already contains the implementation; compiling +# tinygltf.cpp alongside it produces duplicate symbols — a hard error on Apple's +# linker starting with Xcode 26. +get_target_property(_tinygltf_aliased tinygltf::tinygltf ALIASED_TARGET) +if(NOT _tinygltf_aliased) + set(_tinygltf_aliased tinygltf::tinygltf) +endif() +get_target_property(_tinygltf_type ${_tinygltf_aliased} TYPE) +if(_tinygltf_type STREQUAL "INTERFACE_LIBRARY") + target_sources(usdGltf PRIVATE "tinygltf.cpp") +endif() +unset(_tinygltf_aliased) +unset(_tinygltf_type) + target_include_directories(usdGltf PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" @@ -57,29 +80,39 @@ endif() # Allow an option for deferring the path replacement to install time if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) - set(PLUG_INFO_LIBRARY_PATH "\$\{PLUG_INFO_LIBRARY_PATH\}") + # We still need to go through `configure_file` even with `USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT` because we burn additional CMake variable beyond PLUG_INFO_LIBRARY_PATH + # So we set `PLUG_INFO_LIBRARY_PATH` as a no op value and let other CMake variables being burnt in + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") else() set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}usdGltf${CMAKE_SHARED_LIBRARY_SUFFIX}") endif() -configure_file(plugInfo.json.in plugInfo.json) -set_target_properties(usdGltf PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) -set_target_properties(usdGltf PROPERTIES RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") +configure_file(plugInfo.json.in plugInfo.json) +set_property(TARGET usdGltf APPEND PROPERTY RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json") +set_property(TARGET usdGltf APPEND PROPERTY RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") # USDGLTF_DESTINATION is set in the parent scope by the add_usd_fileformat macro if(USDGLTF_ENABLE_INSTALL) + set_property(TARGET usdGltf + APPEND PROPERTY + INSTALL_RPATH "${plugin_install_rpath_root}/." + ) install( TARGETS usdGltf + EXPORT usd-fileformats-targets RUNTIME DESTINATION ${USDGLTF_DESTINATION} COMPONENT Runtime LIBRARY DESTINATION ${USDGLTF_DESTINATION} COMPONENT Runtime + ARCHIVE DESTINATION ${USDGLTF_DESTINATION} COMPONENT Runtime RESOURCE DESTINATION ${USDGLTF_DESTINATION}/usdGltf/resources COMPONENT Runtime ) - install( - FILES plugInfo.root.json - DESTINATION ${USDGLTF_DESTINATION} - RENAME plugInfo.json - COMPONENT Runtime - ) + if(USD_FILEFORMATS_ENABLE_INSTALL_PLUGINFO_ROOT) + install( + FILES plugInfo.root.json + DESTINATION ${USDGLTF_DESTINATION} + RENAME plugInfo.json + COMPONENT Runtime + ) + endif() endif() diff --git a/gltf/src/fileFormat.cpp b/gltf/src/fileFormat.cpp index 0d92965e..7afe00a4 100644 --- a/gltf/src/fileFormat.cpp +++ b/gltf/src/fileFormat.cpp @@ -36,6 +36,7 @@ using namespace adobe::usd; const TfToken UsdGltfFileFormat::assetsPathToken("gltfAssetsPath", TfToken::Immortal); const TfToken UsdGltfFileFormat::animationTracksToken("gltfAnimationTracks", TfToken::Immortal); const TfToken UsdGltfFileFormat::computeBitangentsToken("computeBitangents", TfToken::Immortal); +const TfToken UsdGltfFileFormat::importLightsToken("importLights", TfToken::Immortal); TF_DEFINE_PUBLIC_TOKENS(UsdGltfFileFormatTokens, USDGLTF_FILE_FORMAT_TOKENS); @@ -72,6 +73,7 @@ UsdGltfFileFormat::InitData(const FileFormatArguments& args) const argReadBool(args, animationTracksToken.GetString(), pd->animationTracks, DEBUG_TAG); argReadBool(args, computeBitangentsToken.GetString(), pd->computeBitangents, DEBUG_TAG); + argReadBool(args, importLightsToken.GetString(), pd->importLights, DEBUG_TAG); return pd; } @@ -165,6 +167,7 @@ UsdGltfFileFormat::Read(PXR_NS::SdfLayer* layer, options.importGeometry = true; options.importMaterials = true; options.importImages = true; + options.importLights = data->importLights; options.computeBitangents = data->computeBitangents; GUARD(importGltf(options, gltf, usd, resolvedPath), "Error translating glTF to USD\n"); @@ -215,6 +218,7 @@ UsdGltfFileFormat::ReadFromString(SdfLayer* layer, const std::string& str) const options.importGeometry = true; options.importMaterials = true; options.importImages = true; + options.importLights = data->importLights; options.computeBitangents = data->computeBitangents; GUARD(importGltf(options, gltf, usd, ""), "Error translating glTF to USD\n"); diff --git a/gltf/src/fileFormat.h b/gltf/src/fileFormat.h index ff0eb55e..0d87e4fa 100644 --- a/gltf/src/fileFormat.h +++ b/gltf/src/fileFormat.h @@ -44,6 +44,7 @@ class GltfData : public FileFormatDataBase public: bool animationTracks = false; bool computeBitangents = false; + bool importLights = true; static GltfDataRefPtr InitData(const SdfFileFormat::FileFormatArguments& args); }; @@ -102,6 +103,7 @@ class USDGLTF_API UsdGltfFileFormat static const TfToken assetsPathToken; static const TfToken animationTracksToken; static const TfToken computeBitangentsToken; + static const TfToken importLightsToken; SDF_FILE_FORMAT_FACTORY_ACCESS; diff --git a/gltf/src/gltf.cpp b/gltf/src/gltf.cpp index 7eaaa3f6..4f40cb25 100644 --- a/gltf/src/gltf.cpp +++ b/gltf/src/gltf.cpp @@ -57,7 +57,7 @@ getImage(const tinygltf::Model* model, size_t textureIndex) return nullptr; } const tinygltf::Texture& texture = model->textures[textureIndex]; - if (texture.source < 0 || texture.source >= model->images.size()) { + if (texture.source < 0 || texture.source >= static_cast(model->images.size())) { TF_WARN("Invalid texture source index: %d", texture.source); return nullptr; } @@ -133,7 +133,8 @@ preValidateGLB(const unsigned char* buffer, size_t bufferSize) // Check if GLB file (magic number 'glTF') if (header[0] != 0x46546C67) { - TF_WARN("Binary file missing GLB magic number (expected 0x46546C67)", header[0]); + TF_WARN("Binary file missing GLB magic number (got 0x%08x, expected 0x46546C67)", + header[0]); return false; // Reject invalid binary files } @@ -520,7 +521,7 @@ getAccessorElementCount(const tinygltf::Model& model, int accessorIndex) } void -readAccessorData(const tinygltf::Model& model, int accessorIndex, uint8_t* dst) +readAccessorData(const tinygltf::Model& model, int accessorIndex, uint8_t* dst, size_t dstByteCount) { if (accessorIndex < 0) { TF_CODING_ERROR("Accessor index %d is invalid (< 0). File should be rejected.", @@ -589,6 +590,22 @@ readAccessorData(const tinygltf::Model& model, int accessorIndex, uint8_t* dst) return; } + // Validate destination capacity to prevent buffer overflow attacks. The destination is sized + // by the caller from the GLTF semantic, but the number of bytes written is driven by the + // file-declared accessor.type/componentType; reject any mismatch that would overflow dst. + // accessor.count is an attacker-controlled size_t, so compare in division form: a direct + // accessor.count * elementSize could wrap size_t, land small, pass this check, and still let + // the write loop (which iterates the real accessor.count) overflow dst. + if (elementSize != 0 && accessor.count > dstByteCount / elementSize) { + TF_WARN("Accessor %d (%zu elements x %zu bytes) exceeds destination capacity %zu bytes. " + "Skipping to prevent buffer overflow.", + accessorIndex, + accessor.count, + elementSize, + dstByteCount); + return; + } + const uint8_t* src = buffer.data.data() + bufferView.byteOffset + accessor.byteOffset; if (elementStride == elementSize) { memcpy(dst, src, accessor.count * elementSize); @@ -621,9 +638,15 @@ normalizedFloat(float value) return value; } -// This function copies/converts a buffer of an accessor component type to a buffer of floats +// This function copies/converts a buffer of an accessor component type to a buffer of floats. +// dstFloatCount is the number of floats the destination buffer can hold; it is validated against +// the element count and component count declared by the file before any write, so a caller that +// sized its destination from the GLTF semantic cannot be overflowed by a mismatched accessor.type. void -readAccessorDataToFloat(const tinygltf::Model& model, int accessorIndex, float* dst) +readAccessorDataToFloat(const tinygltf::Model& model, + int accessorIndex, + float* dst, + size_t dstFloatCount) { if (accessorIndex < 0) { TF_CODING_ERROR("Accessor index %d is invalid (< 0). File should be rejected.", @@ -693,6 +716,22 @@ readAccessorDataToFloat(const tinygltf::Model& model, int accessorIndex, float* return; } + // Validate destination capacity to prevent buffer overflow attacks. The destination is sized + // by the caller from the GLTF semantic, but the number of floats written is driven by the + // file-declared accessor.type (componentCount); reject any mismatch that would overflow dst. + // accessor.count is an attacker-controlled size_t, so compare in division form: a direct + // accessor.count * componentCount could wrap size_t, land small, pass this check, and still let + // the conversion loop (which iterates the real accessor.count) overflow dst. + if (componentCount != 0 && accessor.count > dstFloatCount / componentCount) { + TF_WARN("Accessor %d (%zu elements x %zu components) exceeds destination capacity %zu " + "floats. Skipping to prevent buffer overflow.", + accessorIndex, + accessor.count, + componentCount, + dstFloatCount); + return; + } + const uint8_t* src = buffer.data.data() + bufferView.byteOffset + accessor.byteOffset; const size_t elementCount = accessor.count; if (accessor.componentType == TINYGLTF_COMPONENT_TYPE_FLOAT) { @@ -800,7 +839,8 @@ _readVec4Color(const tinygltf::Model& model, VtArray& opacity) { std::vector temp(colorCount * 4); - readAccessorData(model, colorsIndex, reinterpret_cast(temp.data())); + readAccessorData( + model, colorsIndex, reinterpret_cast(temp.data()), temp.size() * sizeof(T)); color.resize(colorCount); opacity.resize(colorCount); for (int i = 0; i < colorCount; i++) { @@ -819,7 +859,8 @@ _readVec3Color(const tinygltf::Model& model, VtArray& color) { std::vector temp(colorCount * 3); - readAccessorData(model, colorsIndex, reinterpret_cast(temp.data())); + readAccessorData( + model, colorsIndex, reinterpret_cast(temp.data()), temp.size() * sizeof(T)); color.resize(colorCount); for (int i = 0; i < colorCount; i++) { color[i] = GfVec3f(normalizedFloat(temp[3 * i]), @@ -862,7 +903,10 @@ readColor(const tinygltf::Model& model, if (accessor.componentType == TINYGLTF_COMPONENT_TYPE_FLOAT) { // No conversion necessary. We can just read the data color.resize(colorCount); - readAccessorData(model, colorsIndex, reinterpret_cast(color.data())); + readAccessorData(model, + colorsIndex, + reinterpret_cast(color.data()), + color.size() * sizeof(PXR_NS::GfVec3f)); } else if (accessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT) { _readVec3Color(model, colorsIndex, colorCount, color); } else if (accessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE) { @@ -928,14 +972,21 @@ readAccessorInts(const tinygltf::Model& model, int componentSize = tinygltf::GetComponentSizeInBytes(accessor.componentType); if (componentSize == 1) { PXR_NS::VtArray temp(dst.size()); - readAccessorData(model, accessorIndex, reinterpret_cast(temp.data())); + readAccessorData(model, + accessorIndex, + reinterpret_cast(temp.data()), + temp.size() * sizeof(uint8_t)); dst.assign(temp.begin(), temp.end()); } else if (componentSize == 2) { PXR_NS::VtArray temp(dst.size()); - readAccessorData(model, accessorIndex, reinterpret_cast(temp.data())); + readAccessorData(model, + accessorIndex, + reinterpret_cast(temp.data()), + temp.size() * sizeof(uint16_t)); dst.assign(temp.begin(), temp.end()); } else { // must be == 4 - readAccessorData(model, accessorIndex, reinterpret_cast(dst.data())); + readAccessorData( + model, accessorIndex, reinterpret_cast(dst.data()), dst.size() * sizeof(int)); } } @@ -1051,47 +1102,43 @@ packBase64String(const std::uint8_t* inputData, return true; } -// Performs bilinear sampling on the given tinygltf::Image*. +// Performs bilinear sampling on the given const tinygltf::Image&. float -sampleBilinear(const tinygltf::Image* image, float ncx, float ncy, int channel) +sampleBilinear(const tinygltf::Image& image, float ncx, float ncy, int channel) { float ret = 0.0f; - if (image != nullptr) { - if (channel < image->component) { - size_t width = image->width; - size_t height = image->height; - - float u = ncx * (width - 1); - float v = ncy * (height - 1); - - size_t x0 = static_cast(std::floor(u)); - size_t x1 = std::min(x0 + 1, width - 1); - size_t y0 = static_cast(std::floor(v)); - size_t y1 = std::min(y0 + 1, height - 1); - - float fx = u - x0; - float fy = v - y0; - - size_t idx00 = (y0 * width + x0) * image->component + channel; - size_t idx10 = (y0 * width + x1) * image->component + channel; - size_t idx01 = (y1 * width + x0) * image->component + channel; - size_t idx11 = (y1 * width + x1) * image->component + channel; - - float c00 = static_cast(image->image[idx00]) / MAX_COLOR_VALUE; - float c10 = static_cast(image->image[idx10]) / MAX_COLOR_VALUE; - float c01 = static_cast(image->image[idx01]) / MAX_COLOR_VALUE; - float c11 = static_cast(image->image[idx11]) / MAX_COLOR_VALUE; - - float c0 = c00 * (1 - fx) + c10 * fx; - float c1 = c01 * (1 - fx) + c11 * fx; - ret = c0 * (1 - fy) + c1 * fy; - } else { - TF_WARN( - "Channel %d is out of bounds for image with %d channels", channel, image->component); - } + if (channel < image.component) { + size_t width = image.width; + size_t height = image.height; + + float u = ncx * (width - 1); + float v = ncy * (height - 1); + + size_t x0 = static_cast(std::floor(u)); + size_t x1 = std::min(x0 + 1, width - 1); + size_t y0 = static_cast(std::floor(v)); + size_t y1 = std::min(y0 + 1, height - 1); + + float fx = u - x0; + float fy = v - y0; + + size_t idx00 = (y0 * width + x0) * image.component + channel; + size_t idx10 = (y0 * width + x1) * image.component + channel; + size_t idx01 = (y1 * width + x0) * image.component + channel; + size_t idx11 = (y1 * width + x1) * image.component + channel; + + float c00 = static_cast(image.image[idx00]) / MAX_COLOR_VALUE; + float c10 = static_cast(image.image[idx10]) / MAX_COLOR_VALUE; + float c01 = static_cast(image.image[idx01]) / MAX_COLOR_VALUE; + float c11 = static_cast(image.image[idx11]) / MAX_COLOR_VALUE; + + float c0 = c00 * (1 - fx) + c10 * fx; + float c1 = c01 * (1 - fx) + c11 * fx; + ret = c0 * (1 - fy) + c1 * fy; } else { - TF_WARN("Image is null"); + TF_WARN("Channel %d is out of bounds for image with %d channels", channel, image.component); } + return ret; } } diff --git a/gltf/src/gltf.h b/gltf/src/gltf.h index 2f510405..f4fedbec 100644 --- a/gltf/src/gltf.h +++ b/gltf/src/gltf.h @@ -79,9 +79,15 @@ getPrimitiveAttribute(const tinygltf::Primitive& primitive, const std::string& n size_t getAccessorElementCount(const tinygltf::Model& model, int accessorIndex); void -readAccessorData(const tinygltf::Model& model, int accessorIndex, uint8_t* dst); +readAccessorData(const tinygltf::Model& model, + int accessorIndex, + uint8_t* dst, + size_t dstByteCount); void -readAccessorDataToFloat(const tinygltf::Model& model, int accessorIndex, float* dst); +readAccessorDataToFloat(const tinygltf::Model& model, + int accessorIndex, + float* dst, + size_t dstFloatCount); bool readAccessorMinMax(const tinygltf::Model& model, int accessorIndex, @@ -144,6 +150,6 @@ packBase64String(const std::uint8_t* inputData, std::string& b64Str); float -sampleBilinear(const tinygltf::Image* image, float ncx, float ncy, int channel); +sampleBilinear(const tinygltf::Image& image, float ncx, float ncy, int channel); } \ No newline at end of file diff --git a/gltf/src/gltfAnisotropy.cpp b/gltf/src/gltfAnisotropy.cpp index 2c465e53..1b15133e 100644 --- a/gltf/src/gltfAnisotropy.cpp +++ b/gltf/src/gltfAnisotropy.cpp @@ -11,19 +11,13 @@ governing permissions and limitations under the License. */ #include "gltfAnisotropy.h" #include "debugCodes.h" -#include "gltfImport.h" -#include -#include -#include -#include +#include "gltfExport.h" using namespace PXR_NS; namespace adobe::usd { -constexpr double PI = 3.14159265358979311600; - // Anisotropy textures can be 4x4 representing a single strength and rotation constexpr size_t SINGLE_VALUE_IMAGE_DIM_SIZE = 4; @@ -35,135 +29,6 @@ isSingleValueImage(const Image& image) image.height == SINGLE_VALUE_IMAGE_DIM_SIZE; } -// Calculates the ASM anisotropy level based on strength and roughness. -float -calculateASMLevel(float strength, float roughness) -{ - float s2 = strength * strength; - return std::sqrt(std::sqrt((1.0f - roughness * roughness) * s2)); -} - -// Reverses the anisotropy strength calculation -float -reverseASMLevel(float anisoLevel, float anisScale, float roughness) -{ - if (roughness > 1.0f) { - // disabling this log for now to prevent spam from bad roughness textures - // TF_WARN("Roughness is too high; cannot reverse calculate strength."); - return 0.0f; - } - float denominator = 1.0f - roughness * roughness; - if (denominator <= 0.0f) { - return 0.0f; - } - float strengthSquared = std::pow(anisoLevel, 4) / denominator; - return std::sqrt(strengthSquared) / anisScale; -} - -// Calculate the ASM anisotropy rotation -float -calculateASMRotation(float angle) -{ - // Normalize the angle to [0, 1) - float normalized_angle = angle / (2.0f * PI); - normalized_angle -= std::floor(normalized_angle); // Ensure it's within [0, 1) - return normalized_angle; -} - -// Calculates the normalized ASM anisotropy angle from red and green channel values. -float -calculateASMImageRotation(float redChannelValue, float greenChannelValue, float rotation) -{ - // Convert channel values from [0, 1] to [-1, 1] - GfVec2f vec(redChannelValue * 2.0f - 1.0f, greenChannelValue * 2.0f - 1.0f); - - // Calculate the angle in radians and apply rotation - float angle = std::atan2(vec[1], vec[0]) + rotation; - float normalized_angle = calculateASMRotation(angle); - return normalized_angle; -} - -// Reverses the normalization and rotation to retrieve the original angle in radians. -float -reverseASMRotation(float normalized_angle, float rotation) -{ - float angle = normalized_angle * (2.0f * PI); - float original_angle = angle - rotation; - original_angle = std::fmod(original_angle, 2.0f * PI); - if (original_angle < 0.0f) { - original_angle += 2.0f * PI; - } - - return original_angle; -} - -// Reverses the calculation of normalized_angle to obtain red and green channel values. -void -reverseCalculateASMImageRotation(float normalized_angle, - float rotation, - float& redChannelValue, - float& greenChannelValue) -{ - float original_angle = reverseASMRotation(normalized_angle, rotation); - - // Convert angle back to vector components - float x = std::cos(original_angle); - float y = std::sin(original_angle); - redChannelValue = (x + 1.0f) / 2.0f; - greenChannelValue = (y + 1.0f) / 2.0f; -} - -// Generates a unique name for the anisotropy image based on prefix, level, and rotation. -std::string -generateAnisotropyImageName(const std::string& prefix, float level, float rotation) -{ - std::stringstream ss; - ss << std::fixed << std::setprecision(3); - ss << prefix << "_" << level << "_" << rotation; - std::string result = ss.str(); - std::replace(result.begin(), result.end(), '.', '_'); - return result; -} - -// Extracts the level and rotation values from a formatted anisotropy image name. -bool -extractAnisotropyParamsFromName(const std::string& name, float& level, float& rotation) -{ - level = -1.0f; - rotation = -1.0f; - std::stringstream ss(name); - std::string segment; - std::vector tokens; - - // Split the string by '_' - while (std::getline(ss, segment, '_')) { - tokens.emplace_back(segment); - } - if (tokens.size() < 5) { - TF_WARN("Error: Input string does not contain enough segments."); - return false; - } - std::string levelPart1 = tokens[tokens.size() - 4]; - std::string levelPart2 = tokens[tokens.size() - 3]; - std::string rotationPart1 = tokens[tokens.size() - 2]; - std::string rotationPart2 = tokens[tokens.size() - 1]; - std::string levelStr = levelPart1 + "." + levelPart2; - std::string rotationStr = rotationPart1 + "." + rotationPart2; - try { - level = std::stof(levelStr); - } catch (const std::exception& e) { - TF_WARN("Error: Failed to convert level string to float. Exception: %s", e.what()); - return false; - } - try { - rotation = std::stof(rotationStr); - } catch (const std::exception& e) { - TF_WARN("Error: Failed to convert rotation string to float. Exception: %s", e.what()); - return false; - } - return true; -} - // Caches an image by writing it and updating the cache map. int cacheAndWriteImage(ImportGltfContext& ctx, @@ -181,406 +46,37 @@ cacheAndWriteImage(ImportGltfContext& ctx, } cache[key] = newIndex; - imageWrite(usdImage, "test/" + usdImage.uri, true); - return newIndex; -} - -// Extracts the roughness value from a roughness image. -float -extractRoughness(const tinygltf::Image* roughnessImage, - bool bilinearRoughnessSampling, - float ncx, - float ncy, - bool normalize = false) -{ - if (roughnessImage) { - if (bilinearRoughnessSampling) { - return sampleBilinear(roughnessImage, ncx, ncy, 0); - } else { - // Perform nearest-neighbor sampling to retrieve roughness - size_t linearX = static_cast(ncx * roughnessImage->width); - size_t linearY = static_cast(ncy * roughnessImage->height); - size_t linearIndex = - (linearY * roughnessImage->width + linearX) * roughnessImage->component; - if (linearIndex >= roughnessImage->image.size()) { - TF_WARN("Linear index out of bounds in roughness image."); - return 0.0f; - } + // This commented call saves the image to disk which is a debugging aid. It should not be + // enabled for normal operation. + // + // imageWrite(usdImage, "test/" + usdImage.uri, true); - float roughness = static_cast(roughnessImage->image[linearIndex]); - if (normalize) { - roughness /= MAX_COLOR_VALUE; - } - return roughness; - } - } else { - TF_WARN("Roughness image is null."); - return 0.0f; - } -} - -// Processes anisotropy pixels and populates anisotropy level and angle images. -void -processAnisotropyPixels(const Image& anisotropyImage, - const tinygltf::Image* roughnessImage, - float roughness, - bool bilinearRoughnessSampling, - const AnisotropyData& anisotropyData, - Image& anisoLevelImage, - Image& anisoAngleImage) -{ - anisoLevelImage.allocate(anisotropyImage.width, anisotropyImage.height, 1); - float* anisoLevelPixels = anisoLevelImage.pixels.data(); - anisoAngleImage.allocate(anisotropyImage.width, anisotropyImage.height, 1); - float* anisoAnglePixels = anisoAngleImage.pixels.data(); - - for (size_t y = 0; y < anisotropyImage.height; ++y) { - float ncy = static_cast(y) / anisotropyImage.height; - for (size_t x = 0; x < anisotropyImage.width; ++x) { - float ncx = static_cast(x) / anisotropyImage.width; - size_t index = (y * anisotropyImage.width + x) * anisotropyImage.channels; - float redChannelValue = anisotropyImage.pixels[index]; - float greenChannelValue = anisotropyImage.pixels[index + 1]; - float blueChannelValue = anisotropyImage.pixels[index + 2]; - - if (roughnessImage) { - roughness = - extractRoughness(roughnessImage, bilinearRoughnessSampling, ncx, ncy, false); - } - - // Calculate and set anisotropy level for every pixel - anisoLevelPixels[y * anisotropyImage.width + x] = - calculateASMLevel(blueChannelValue * anisotropyData.strength, roughness); - - // Calculate and set anisotropy rotation for every pixel - float normalized_angle = calculateASMImageRotation( - redChannelValue, greenChannelValue, anisotropyData.rotation); - anisoAnglePixels[y * anisotropyImage.width + x] = normalized_angle; - } - } + return newIndex; } -// Processes anisotropy pixels with roughness, populating the anisotropy level void -processAnisotropyPixelsFromRoughness(const AnisotropyData& anisotropyData, - const tinygltf::Image* roughnessImage, - bool bilinearRoughnessSampling, - Image& anisoLevelImage) -{ - anisoLevelImage.allocate(roughnessImage->width, roughnessImage->height, 1); - float* anisoLevelPixels = anisoLevelImage.pixels.data(); - for (size_t y = 0; y < roughnessImage->height; ++y) { - float ncy = static_cast(y) / roughnessImage->height; - for (size_t x = 0; x < roughnessImage->width; ++x) { - float ncx = static_cast(x) / roughnessImage->width; - float roughness = extractRoughness(roughnessImage, bilinearRoughnessSampling, ncx, ncy); - anisoLevelPixels[y * roughnessImage->width + x] = - calculateASMLevel(anisotropyData.strength, roughness); - } - } -} - -// Gathers the anisotropy data from a glTF material and imports non image ASM values. -bool -importAnisotropyData(ImportGltfContext& ctx, - const tinygltf::ExtensionMap& extensions, - const tinygltf::Value& anisoExt, - Material& m, - float roughness, - AnisotropyData& anisotropy, - Image& anisotropySrcImage) +addRotationToAnisotropyExt(tinygltf::ExtensionMap& ext, float rotation) { - TF_DEBUG_MSG(FILE_FORMAT_GLTF, "importAnisotropyData for material '%s'\n", m.name.c_str()); - bool ret = false; - bool haveStrength = readDoubleValue(anisoExt.Get("anisotropyStrength"), anisotropy.strength); - bool haveRotation = readDoubleValue(anisoExt.Get("anisotropyRotation"), anisotropy.rotation); - readTextureInfo(anisoExt.Get("anisotropyTexture"), anisotropy.texture); - TF_DEBUG_MSG(FILE_FORMAT_GLTF, " texture.index: %d\n", anisotropy.texture.index); - Input anisotropyInput; - if (anisotropy.texture.index > -1) { - TF_DEBUG_MSG( - FILE_FORMAT_GLTF, " calling importImage with index %d\n", anisotropy.texture.index); - int imageIndex = importImage(ctx, anisotropy.texture.index, m.name, "anisotropy"); - importTexture(ctx.gltf, - imageIndex, - anisotropy.texture.index, - anisotropy.texture.texCoord, - anisotropyInput, - AdobeTokens->rgb, - AdobeTokens->raw); - importTextureTransform(extensions, anisotropyInput); - const ImageAsset& anisotropyImageAsset = ctx.usd->images[anisotropyInput.image]; - anisotropySrcImage.read(anisotropyImageAsset, anisotropySrcImage.channels); - - if (isSingleValueImage(anisotropySrcImage)) { - if (!haveStrength) { - anisotropy.strength = anisotropySrcImage.pixels[2]; - } - - float normalized_angle = calculateASMImageRotation( - anisotropySrcImage.pixels[0], anisotropySrcImage.pixels[1], anisotropy.rotation); - anisotropy.rotation = normalized_angle; - } else { - ret = true; - } - } - - float anisoLevel = calculateASMLevel(anisotropy.strength, roughness); - importValue1(m.anisotropyLevel, anisoLevel); - float asmRotation = calculateASMRotation(anisotropy.rotation); - importValue1(m.anisotropyAngle, asmRotation); - return ret; + addFloatValueToExt(ext, "anisotropyRotation", rotation); } - -// Imports anisotropy textures from a glTF material and updates the USD material. void -importAnisotropyTexture(ImportGltfContext& ctx, - const tinygltf::Material& gm, - Material& m, - float roughness, - const AnisotropyData& anisotropyData, - const Image& anisotropySrcImage, - std::unordered_map& cache) +addStrengthToAnisotropyExt(tinygltf::ExtensionMap& ext, float strength) { - TF_DEBUG_MSG( - FILE_FORMAT_GLTF, "importAnisotropyTexture for material '%s'\n", m.displayName.c_str()); - // Get Roughness image - const tinygltf::Image* roughnessImage = nullptr; - // Validate texture index before use to prevent signed/unsigned comparison bug - int roughnessTexIdx = gm.pbrMetallicRoughness.metallicRoughnessTexture.index; - TF_DEBUG_MSG(FILE_FORMAT_GLTF, " roughness texture index: %d\n", roughnessTexIdx); - if (roughnessTexIdx >= 0 && static_cast(roughnessTexIdx) < ctx.gltf->textures.size()) { - TF_DEBUG_MSG(FILE_FORMAT_GLTF, " calling getImage for roughness\n"); - roughnessImage = getImage(ctx.gltf, roughnessTexIdx); - TF_DEBUG_MSG(FILE_FORMAT_GLTF, " getImage returned: %p\n", roughnessImage); - } - - // Check if the anisotropy textures are already in the cache - std::string levelCacheKey = ""; - std::string angleCacheKey = ""; - Image anisoLevelImage; - Image anisoAngleImage; - if (anisotropyData.texture.index >= 0) { - levelCacheKey = generateAnisotropyImageName(AdobeTokens->anisotropyLevelTexture.GetText(), - anisotropyData.strength, - anisotropyData.rotation); - angleCacheKey = generateAnisotropyImageName(AdobeTokens->anisotropyAngleTexture.GetText(), - anisotropyData.strength, - anisotropyData.rotation); - } else if (gm.pbrMetallicRoughness.metallicRoughnessTexture.index >= 0) { - std::string roughnessPrefix = "_roughness"; - levelCacheKey = generateAnisotropyImageName(AdobeTokens->anisotropyLevelTexture.GetText() + - roughnessPrefix, - anisotropyData.strength, - anisotropyData.rotation); - } - int usdAnisoLevelImageIndex = lookupTexture(cache, levelCacheKey); - int usdAnisoAngleImageIndex = lookupTexture(cache, angleCacheKey); - - bool bilinearRoughnessSampling = - (roughnessImage && (anisotropySrcImage.width != roughnessImage->width || - anisotropySrcImage.height != roughnessImage->height)); - - // Check if we can and need to import the anisotropy textures - if (anisotropySrcImage.width > 0 && anisotropySrcImage.height > 0) { - if (usdAnisoLevelImageIndex < 0 && usdAnisoAngleImageIndex < 0) { - processAnisotropyPixels(anisotropySrcImage, - roughnessImage, - roughness, - bilinearRoughnessSampling, - anisotropyData, - anisoLevelImage, - anisoAngleImage); - - // Not a big fan of this: Reserve here so the second addImage doesn't invalidate - // the first ImageAsset because the vector had to be moved - ctx.usd->reserveImages(2); - usdAnisoLevelImageIndex = - cacheAndWriteImage(ctx, cache, levelCacheKey, anisoLevelImage); - usdAnisoAngleImageIndex = - cacheAndWriteImage(ctx, cache, angleCacheKey, anisoAngleImage); - } - - Input levelTextureInput, angleTextureInput; - setInputImage(m.anisotropyLevel, - usdAnisoLevelImageIndex, - anisotropyData.texture.texCoord, - AdobeTokens->rgb, - AdobeTokens->raw); - setInputImage(m.anisotropyAngle, - usdAnisoAngleImageIndex, - anisotropyData.texture.texCoord, - AdobeTokens->rgb, - AdobeTokens->raw); - } else if (roughnessImage != nullptr && roughnessImage->width > 0 && - roughnessImage->height > 0) { - if (usdAnisoLevelImageIndex < 0) { - // Case where there is no anisotropy image but anisotropy strength and roughness - // image are present - processAnisotropyPixelsFromRoughness( - anisotropyData, roughnessImage, bilinearRoughnessSampling, anisoLevelImage); - usdAnisoLevelImageIndex = - cacheAndWriteImage(ctx, cache, levelCacheKey, anisoLevelImage); - } - Input levelTextureInput; - setInputImage(m.anisotropyLevel, - usdAnisoLevelImageIndex, - anisotropyData.texture.texCoord, - AdobeTokens->rgb, - AdobeTokens->raw); - } + addFloatValueToExt(ext, "anisotropyStrength", strength); } -// Constructs an anisotropy image by combining level and angle images, considering roughness. void -constructAnisotropyImage(const Material& m, - const Image& levelImage, - const Image& angleImage, - float anisScale, - float anisRotation, - const tinygltf::Image* roughnessImage, - Image& constructedAnisotropyImage) +addTextureToAnisotropyExt(tinygltf::ExtensionMap& ext, int texIndex, int texCoord) { - int width = std::max(levelImage.width, angleImage.width); - int height = std::max(levelImage.height, angleImage.height); - - // Check if the roughness image has a different resolution - bool needsRoughnessResample = - (roughnessImage != nullptr) && ((levelImage.width != roughnessImage->width) || - (levelImage.height != roughnessImage->height)); - - size_t numPixels = width * height; - size_t numChannels = constructedAnisotropyImage.channels; - constructedAnisotropyImage.allocate(width, height, numChannels); - - for (size_t i = 0; i < numPixels; ++i) { - size_t idxDst = i * numChannels; - - // Calculate normalized coordinates for bilinear sampling - float roughness = 0.0f; - if (roughnessImage != nullptr) { - if (needsRoughnessResample) { - float u = (i % levelImage.width) / static_cast(levelImage.width); - float v = (i / levelImage.width) / static_cast(levelImage.height); - roughness = sampleBilinear(roughnessImage, u, v, 0); - } else { - roughness = static_cast(roughnessImage->image[i]) / MAX_COLOR_VALUE; - } - } else { - if (m.roughness.value.IsHolding()) { - roughness = m.roughness.value.Get(); - } - } - - // Set anisotropy level (blue channel) - constructedAnisotropyImage.pixels[idxDst + 2] = - reverseASMLevel(levelImage.pixels[i], anisScale, roughness); - - // Reconstruct the red and green channels (from angle) - reverseCalculateASMImageRotation(angleImage.pixels[i], - anisRotation, - constructedAnisotropyImage.pixels[idxDst + 0], - constructedAnisotropyImage.pixels[idxDst + 1]); - } -} - -// Exports the anisotropy extension to a glTF material. -void -exportAnisotropyExtension(ExportGltfContext& ctx, - InputTranslator& inputTranslator, - const Material& m, - tinygltf::Material& gm, - std::unordered_map& constructedAnisotropyCache) -{ - if (ctx.usd != nullptr) { - if (m.anisotropyLevel.value.IsEmpty() && m.anisotropyLevel.image < 0) { - if (m.anisotropyAngle.value.IsEmpty() && m.anisotropyAngle.image < 0) { - // Return if there is no anisotropy data so as to not write out an empty extension - return; - } - } - float reconstructedStrength = 1.0f; - float reconstructedAngle = 0.0f; - tinygltf::ExtensionMap ext; - if (m.anisotropyLevel.value.IsHolding()) { - // Use default roughness if none is available - float roughness = - m.roughness.value.IsHolding() ? m.roughness.value.UncheckedGet() : 0.0f; - reconstructedStrength = - reverseASMLevel(m.anisotropyLevel.value.UncheckedGet(), 1.0f, roughness); - addFloatValueToExt(ext, "anisotropyStrength", reconstructedStrength); - } - - if (m.anisotropyAngle.value.IsHolding()) { - reconstructedAngle = - reverseASMRotation(m.anisotropyAngle.value.UncheckedGet(), 0.0f); - addFloatValueToExt(ext, "anisotropyRotation", reconstructedAngle); - } - - if (m.anisotropyLevel.image >= 0 || m.anisotropyAngle.image >= 0) { - std::string anisLevelName = inputTranslator.getImageSourceName(m.anisotropyLevel.image); - if (extractAnisotropyParamsFromName( - anisLevelName, reconstructedStrength, reconstructedAngle)) { - addFloatValueToExt(ext, "anisotropyStrength", reconstructedStrength); - addFloatValueToExt(ext, "anisotropyRotation", reconstructedAngle); - } - std::string constructedTextureName = "anisotropyTexture_" + - std::to_string(m.anisotropyLevel.image) + "_" + - std::to_string(m.anisotropyAngle.image); - Input& constructedAnisotropyInput = constructedAnisotropyCache[constructedTextureName]; - if (constructedAnisotropyInput.image < 0) { - Image constructedImage; - constructedImage.channels = 3; - - // Decode level and angle images - auto decodedLevel = inputTranslator.getDecodedImage(m.anisotropyLevel.image); - auto decodedAngle = inputTranslator.getDecodedImage(m.anisotropyAngle.image); - Image anisotropyLevelImage = decodedLevel.second; - Image anisotropyAngleImage = decodedAngle.second; - - const tinygltf::Image* roughnessImage = nullptr; - if (gm.pbrMetallicRoughness.metallicRoughnessTexture.index >= 0 && - gm.pbrMetallicRoughness.metallicRoughnessTexture.index < - ctx.gltf->textures.size()) { - roughnessImage = - getImage(ctx.gltf, gm.pbrMetallicRoughness.metallicRoughnessTexture.index); - } - - constructAnisotropyImage(m, - anisotropyLevelImage, - anisotropyAngleImage, - reconstructedStrength, - reconstructedAngle, - roughnessImage, - constructedImage); - constructedAnisotropyInput.image = inputTranslator.addImage( - std::move(constructedImage), constructedTextureName, ImageFormatPng, false); - int textureIndex = -1; - int texCoord = -1; - exportTexture(ctx, constructedAnisotropyInput, textureIndex, texCoord); - if (textureIndex != -1) { - std::map textureInfo; - textureInfo["index"] = tinygltf::Value(constructedAnisotropyInput.image); - if (texCoord != 0) { - textureInfo["texCoord"] = tinygltf::Value(texCoord); - } - ext["anisotropyTexture"] = tinygltf::Value(textureInfo); - } - } else { - if (constructedAnisotropyInput.image > -1) { - std::map textureInfo; - textureInfo["index"] = tinygltf::Value(constructedAnisotropyInput.image); - if (constructedAnisotropyInput.uvIndex != 0) { - textureInfo["texCoord"] = - tinygltf::Value(constructedAnisotropyInput.uvIndex); - } - ext["anisotropyTexture"] = tinygltf::Value(textureInfo); - } - } - } - addMaterialExt(ctx, gm, "KHR_materials_anisotropy", ext); + // add anisotropy texture info to the extension + std::map textureInfo; + textureInfo["index"] = tinygltf::Value(texIndex); + // the default texCoord is 0, so only add it to the extension if it's greater than 0 + if (texCoord > 0) { + textureInfo["texCoord"] = tinygltf::Value(texCoord); } + ext["anisotropyTexture"] = tinygltf::Value(textureInfo); } } // end namespace adobe::usd diff --git a/gltf/src/gltfAnisotropy.h b/gltf/src/gltfAnisotropy.h index c285694e..39ba879c 100644 --- a/gltf/src/gltfAnisotropy.h +++ b/gltf/src/gltfAnisotropy.h @@ -11,14 +11,15 @@ governing permissions and limitations under the License. */ #pragma once #include "gltf.h" -#include "gltfExport.h" #include "importGltfContext.h" #include -#include +#include #include namespace adobe::usd { +constexpr double PI = 3.14159265358979311600; + struct AnisotropyData { double strength = 0.0; @@ -26,41 +27,27 @@ struct AnisotropyData tinygltf::TextureInfo texture; // rg are a 2D direction, b is a strength multiplier }; -// Gathers the anisotropy data from a glTF material and imports values. +// Returns true if the image is a 4x4 containing a single anisotropy entry bool -importAnisotropyData(ImportGltfContext& ctx, - const tinygltf::ExtensionMap& extensions, - const tinygltf::Value& anisoExt, - Material& m, - float roughness, - AnisotropyData& anisotropy, - Image& anisotropySrcImage); +isSingleValueImage(const Image& image); + +// Caches an image by writing it and updating the cache map. +int +cacheAndWriteImage(ImportGltfContext& ctx, + std::unordered_map& cache, + const std::string& key, + const Image& image); -// Imports anisotropy textures from a glTF material and updates the USD material. +// Adds anisotropyRotation key/value to the extension map. void -importAnisotropyTexture(ImportGltfContext& ctx, - const tinygltf::Material& gm, - Material& m, - float roughness, - const AnisotropyData& anisotropyData, - const Image& anisotropySrcImage, - std::unordered_map& cache); +addRotationToAnisotropyExt(tinygltf::ExtensionMap& ext, float rotation); -// Constructs an anisotropy image by combining level and angle images, considering roughness. +// Adds anisotropyStrength key/value to the extension map. void -constructAnisotropyImage(const Material& m, - const Image& levelImage, - const Image& angleImage, - float anisScale, - float anisRotation, - const tinygltf::Image* roughnessImage, - Image& constructedAnisotropyImage); +addStrengthToAnisotropyExt(tinygltf::ExtensionMap& ext, float strength); -// Exports the anisotropy extension to a glTF material. +// Adds anisotropyTexture key/value to the extension map. void -exportAnisotropyExtension(ExportGltfContext& ctx, - InputTranslator& inputTranslator, - const Material& m, - tinygltf::Material& gm, - std::unordered_map& constructedAnisotropyCache); +addTextureToAnisotropyExt(tinygltf::ExtensionMap& ext, int texIndex, int texCoord); + } // end namespace adobe::usd diff --git a/gltf/src/gltfAnisotropyASM.cpp b/gltf/src/gltfAnisotropyASM.cpp new file mode 100644 index 00000000..73da683b --- /dev/null +++ b/gltf/src/gltfAnisotropyASM.cpp @@ -0,0 +1,594 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#include "gltfAnisotropyASM.h" +#include "debugCodes.h" +#include "gltfAnisotropy.h" +#include "gltfImport.h" +#include "importGltfContext.h" +#include +#include +#include +#include + +#include + +using namespace PXR_NS; + +namespace adobe::usd { + +// Calculates the ASM anisotropy level based on strength and roughness. +float +calculateASMLevel(float strength, float roughness) +{ + float s2 = strength * strength; + return std::sqrt(std::sqrt((1.0f - roughness * roughness) * s2)); +} + +// Reverses the anisotropy strength calculation +float +reverseASMLevel(float anisoLevel, float anisScale, float roughness) +{ + if (roughness > 1.0f) { + // disabling this log for now to prevent spam from bad roughness textures + // TF_WARN("Roughness is too high; cannot reverse calculate strength."); + return 0.0f; + } + float denominator = 1.0f - roughness * roughness; + if (denominator <= 0.0f) { + return 0.0f; + } + float strengthSquared = std::pow(anisoLevel, 4) / denominator; + return std::sqrt(strengthSquared) / anisScale; +} + +// Calculate the ASM anisotropy rotation +float +calculateASMRotation(float angle) +{ + // Normalize the angle to [0, 1) + float normalized_angle = angle / (2.0f * PI); + normalized_angle -= std::floor(normalized_angle); // Ensure it's within [0, 1) + return normalized_angle; +} + +// Calculates the normalized ASM anisotropy angle from red and green channel values. +float +calculateASMImageRotation(float redChannelValue, float greenChannelValue, float rotation) +{ + // Convert channel values from [0, 1] to [-1, 1] + GfVec2f vec(redChannelValue * 2.0f - 1.0f, greenChannelValue * 2.0f - 1.0f); + + // Calculate the angle in radians and apply rotation + float angle = std::atan2(vec[1], vec[0]) + rotation; + float normalized_angle = calculateASMRotation(angle); + return normalized_angle; +} + +// Reverses the normalization and rotation to retrieve the original angle in radians. +float +reverseASMRotation(float normalized_angle, float rotation) +{ + float angle = normalized_angle * (2.0f * PI); + float original_angle = angle - rotation; + original_angle = std::fmod(original_angle, 2.0f * PI); + if (original_angle < 0.0f) { + original_angle += 2.0f * PI; + } + + return original_angle; +} + +// Reverses the calculation of normalized_angle to obtain red and green channel values. +void +reverseCalculateASMImageRotation(float normalized_angle, + float rotation, + float& redChannelValue, + float& greenChannelValue) +{ + float original_angle = reverseASMRotation(normalized_angle, rotation); + + // Convert angle back to vector components + float x = std::cos(original_angle); + float y = std::sin(original_angle); + redChannelValue = (x + 1.0f) / 2.0f; + greenChannelValue = (y + 1.0f) / 2.0f; +} + +// Generates a unique name for the anisotropy image based on prefix, level, and rotation. +std::string +generateAnisotropyImageName(const std::string& prefix, float level, float rotation) +{ + std::stringstream ss; + ss << std::fixed << std::setprecision(3); + ss << prefix << "_" << level << "_" << rotation; + + std::string result = ss.str(); + std::replace(result.begin(), result.end(), '.', '_'); + return result; +} + +// Extracts the level and rotation values from a formatted anisotropy image name. +bool +extractAnisotropyParamsFromName(const std::string& name, float& level, float& rotation) +{ + level = -1.0f; + rotation = -1.0f; + std::stringstream ss(name); + std::string segment; + std::vector tokens; + + // Split the string by '_' + while (std::getline(ss, segment, '_')) { + tokens.emplace_back(segment); + } + if (tokens.size() < 5) { + TF_WARN("Error: Input string does not contain enough segments."); + return false; + } + std::string levelPart1 = tokens[tokens.size() - 4]; + std::string levelPart2 = tokens[tokens.size() - 3]; + std::string rotationPart1 = tokens[tokens.size() - 2]; + std::string rotationPart2 = tokens[tokens.size() - 1]; + std::string levelStr = levelPart1 + "." + levelPart2; + std::string rotationStr = rotationPart1 + "." + rotationPart2; + try { + level = std::stof(levelStr); + } catch (const std::exception& e) { + TF_WARN("Error: Failed to convert level string to float. Exception: %s", e.what()); + return false; + } + try { + rotation = std::stof(rotationStr); + } catch (const std::exception& e) { + TF_WARN("Error: Failed to convert rotation string to float. Exception: %s", e.what()); + return false; + } + return true; +} + +// Extracts the roughness value from a roughness image. +float +extractRoughness(const tinygltf::Image* roughnessImage, + bool bilinearRoughnessSampling, + float ncx, + float ncy, + bool normalize) +{ + if (roughnessImage) { + if (bilinearRoughnessSampling) { + return sampleBilinear(*roughnessImage, ncx, ncy, 0); + } else { + // Perform nearest-neighbor sampling to retrieve roughness + size_t linearX = static_cast(ncx * roughnessImage->width); + size_t linearY = static_cast(ncy * roughnessImage->height); + size_t linearIndex = + (linearY * roughnessImage->width + linearX) * roughnessImage->component; + if (linearIndex >= roughnessImage->image.size()) { + TF_WARN("Linear index out of bounds in roughness image."); + return 0.0f; + } + + float roughness = static_cast(roughnessImage->image[linearIndex]); + if (normalize) { + roughness /= MAX_COLOR_VALUE; + } + return roughness; + } + } else { + TF_WARN("Roughness image is null."); + return 0.0f; + } +} + +// Processes anisotropy pixels and populates anisotropy level and angle images. +void +processAnisotropyPixels(const Image& anisotropyImage, + const tinygltf::Image* roughnessImage, + float roughness, + bool bilinearRoughnessSampling, + const AnisotropyData& anisotropyData, + Image& anisoLevelImage, + Image& anisoAngleImage) +{ + anisoLevelImage.allocate(anisotropyImage.width, anisotropyImage.height, 1); + float* anisoLevelPixels = anisoLevelImage.pixels.data(); + anisoAngleImage.allocate(anisotropyImage.width, anisotropyImage.height, 1); + float* anisoAnglePixels = anisoAngleImage.pixels.data(); + + float strength = static_cast(anisotropyData.strength); + + for (size_t y = 0; y < anisotropyImage.height; ++y) { + float ncy = static_cast(y) / anisotropyImage.height; + for (size_t x = 0; x < anisotropyImage.width; ++x) { + float ncx = static_cast(x) / anisotropyImage.width; + size_t index = (y * anisotropyImage.width + x) * anisotropyImage.channels; + float redChannelValue = anisotropyImage.pixels[index]; + float greenChannelValue = anisotropyImage.pixels[index + 1]; + float blueChannelValue = anisotropyImage.pixels[index + 2]; + + if (roughnessImage) { + roughness = + extractRoughness(roughnessImage, bilinearRoughnessSampling, ncx, ncy, false); + } + + // Calculate and set anisotropy level for every pixel + anisoLevelPixels[y * anisotropyImage.width + x] = + calculateASMLevel(blueChannelValue * strength, roughness); + + // Calculate and set anisotropy rotation for every pixel + float normalized_angle = calculateASMImageRotation( + redChannelValue, greenChannelValue, anisotropyData.rotation); + anisoAnglePixels[y * anisotropyImage.width + x] = normalized_angle; + } + } +} + +// Processes anisotropy pixels with roughness, populating the anisotropy level +void +processAnisotropyPixelsFromRoughness(const AnisotropyData& anisotropyData, + const tinygltf::Image* roughnessImage, + bool bilinearRoughnessSampling, + Image& anisoLevelImage) +{ + anisoLevelImage.allocate(roughnessImage->width, roughnessImage->height, 1); + float* anisoLevelPixels = anisoLevelImage.pixels.data(); + float strength = static_cast(anisotropyData.strength); + for (size_t y = 0; y < roughnessImage->height; ++y) { + float ncy = static_cast(y) / roughnessImage->height; + for (size_t x = 0; x < roughnessImage->width; ++x) { + float ncx = static_cast(x) / roughnessImage->width; + float roughness = + extractRoughness(roughnessImage, bilinearRoughnessSampling, ncx, ncy, false); + anisoLevelPixels[y * roughnessImage->width + x] = + calculateASMLevel(strength, roughness); + } + } +} + +// Gathers the anisotropy data from a glTF material and imports non image ASM values. +bool +importAnisotropyData(ImportGltfContext& ctx, + const tinygltf::ExtensionMap& extensions, + const tinygltf::Value& anisoExt, + Material& m, + float roughness, + AnisotropyData& anisotropy, + Image& anisotropySrcImage) +{ + TF_DEBUG_MSG(FILE_FORMAT_GLTF, "importAnisotropyData for material '%s'\n", m.name.c_str()); + bool ret = false; + bool haveStrength = readDoubleValue(anisoExt.Get("anisotropyStrength"), anisotropy.strength); + readDoubleValue(anisoExt.Get("anisotropyRotation"), anisotropy.rotation); + readTextureInfo(anisoExt.Get("anisotropyTexture"), anisotropy.texture); + TF_DEBUG_MSG(FILE_FORMAT_GLTF, " texture.index: %d\n", anisotropy.texture.index); + Input anisotropyInput; + if (anisotropy.texture.index > -1) { + TF_DEBUG_MSG( + FILE_FORMAT_GLTF, " calling importImage with index %d\n", anisotropy.texture.index); + int imageIndex = importImage(ctx, anisotropy.texture.index, m.name, "anisotropy"); + importTexture(ctx.gltf, + imageIndex, + anisotropy.texture.index, + anisotropy.texture.texCoord, + anisotropyInput, + AdobeTokens->rgb, + AdobeTokens->raw); + importTextureTransform(extensions, anisotropyInput); + const ImageAsset& anisotropyImageAsset = ctx.usd->images[anisotropyInput.image]; + anisotropySrcImage.read(anisotropyImageAsset, anisotropySrcImage.channels); + + if (isSingleValueImage(anisotropySrcImage)) { + if (!haveStrength) { + anisotropy.strength = anisotropySrcImage.pixels[2]; + } + + float normalized_angle = calculateASMImageRotation( + anisotropySrcImage.pixels[0], anisotropySrcImage.pixels[1], anisotropy.rotation); + anisotropy.rotation = normalized_angle; + } else { + ret = true; + } + } + + float anisoLevel = calculateASMLevel(anisotropy.strength, roughness); + importValue1(m.anisotropyLevel, anisoLevel); + float asmRotation = calculateASMRotation(anisotropy.rotation); + importValue1(m.anisotropyAngle, asmRotation); + return ret; +} + +// Imports anisotropy textures from a glTF material and updates the USD material. +void +importAnisotropyTexture(ImportGltfContext& ctx, + const tinygltf::Material& gm, + Material& m, + float roughness, + const AnisotropyData& anisotropyData, + const Image& anisotropySrcImage, + std::unordered_map& cache) +{ + TF_DEBUG_MSG( + FILE_FORMAT_GLTF, "importAnisotropyTexture for material '%s'\n", m.displayName.c_str()); + // Get Roughness image + const tinygltf::Image* roughnessImage = nullptr; + // Validate texture index before use to prevent signed/unsigned comparison bug + int roughnessTexIdx = gm.pbrMetallicRoughness.metallicRoughnessTexture.index; + TF_DEBUG_MSG(FILE_FORMAT_GLTF, " roughness texture index: %d\n", roughnessTexIdx); + if (roughnessTexIdx >= 0 && static_cast(roughnessTexIdx) < ctx.gltf->textures.size()) { + TF_DEBUG_MSG(FILE_FORMAT_GLTF, " calling getImage for roughness\n"); + roughnessImage = getImage(ctx.gltf, roughnessTexIdx); + TF_DEBUG_MSG(FILE_FORMAT_GLTF, " getImage returned: %p\n", roughnessImage); + } + + // Check if the anisotropy textures are already in the cache + std::string levelCacheKey = ""; + std::string angleCacheKey = ""; + Image anisoLevelImage; + Image anisoAngleImage; + float strength = static_cast(anisotropyData.strength); + if (anisotropyData.texture.index >= 0) { + levelCacheKey = generateAnisotropyImageName( + AdobeTokens->anisotropyLevelTexture.GetText(), strength, anisotropyData.rotation); + angleCacheKey = generateAnisotropyImageName( + AdobeTokens->anisotropyAngleTexture.GetText(), strength, anisotropyData.rotation); + } else if (gm.pbrMetallicRoughness.metallicRoughnessTexture.index >= 0) { + std::string roughnessPrefix = "_roughness"; + levelCacheKey = generateAnisotropyImageName(AdobeTokens->anisotropyLevelTexture.GetText() + + roughnessPrefix, + strength, + anisotropyData.rotation); + } + int usdAnisoLevelImageIndex = lookupTexture(cache, levelCacheKey); + int usdAnisoAngleImageIndex = lookupTexture(cache, angleCacheKey); + + bool bilinearRoughnessSampling = + (roughnessImage && (anisotropySrcImage.width != roughnessImage->width || + anisotropySrcImage.height != roughnessImage->height)); + + // Check if we can and need to import the anisotropy textures + if (anisotropySrcImage.width > 0 && anisotropySrcImage.height > 0) { + if (usdAnisoLevelImageIndex < 0 && usdAnisoAngleImageIndex < 0) { + processAnisotropyPixels(anisotropySrcImage, + roughnessImage, + roughness, + bilinearRoughnessSampling, + anisotropyData, + anisoLevelImage, + anisoAngleImage); + + // Not a big fan of this: Reserve here so the second addImage doesn't invalidate + // the first ImageAsset because the vector had to be moved + ctx.usd->reserveImages(2); + usdAnisoLevelImageIndex = + cacheAndWriteImage(ctx, cache, levelCacheKey, anisoLevelImage); + usdAnisoAngleImageIndex = + cacheAndWriteImage(ctx, cache, angleCacheKey, anisoAngleImage); + } + + Input levelTextureInput, angleTextureInput; + setInputImage(m.anisotropyLevel, + usdAnisoLevelImageIndex, + anisotropyData.texture.texCoord, + AdobeTokens->rgb, + AdobeTokens->raw); + setInputImage(m.anisotropyAngle, + usdAnisoAngleImageIndex, + anisotropyData.texture.texCoord, + AdobeTokens->rgb, + AdobeTokens->raw); + } else if (roughnessImage != nullptr && roughnessImage->width > 0 && + roughnessImage->height > 0) { + if (usdAnisoLevelImageIndex < 0) { + // Case where there is no anisotropy image but anisotropy strength and roughness + // image are present + processAnisotropyPixelsFromRoughness( + anisotropyData, roughnessImage, bilinearRoughnessSampling, anisoLevelImage); + usdAnisoLevelImageIndex = + cacheAndWriteImage(ctx, cache, levelCacheKey, anisoLevelImage); + } + Input levelTextureInput; + setInputImage(m.anisotropyLevel, + usdAnisoLevelImageIndex, + anisotropyData.texture.texCoord, + AdobeTokens->rgb, + AdobeTokens->raw); + } +} + +// Constructs an anisotropy image by combining level and angle images, considering roughness. +void +constructAnisotropyImage(const Material& m, + const Image& levelImage, + const Image& angleImage, + float anisScale, + float anisRotation, + const tinygltf::Image* roughnessImage, + Image& constructedAnisotropyImage) +{ + int width = std::max(levelImage.width, angleImage.width); + int height = std::max(levelImage.height, angleImage.height); + + // Check if the roughness image has a different resolution + bool needsRoughnessResample = + (roughnessImage != nullptr) && ((levelImage.width != roughnessImage->width) || + (levelImage.height != roughnessImage->height)); + + size_t numPixels = width * height; + size_t numChannels = constructedAnisotropyImage.channels; + constructedAnisotropyImage.allocate(width, height, numChannels); + + for (size_t i = 0; i < numPixels; ++i) { + size_t idxDst = i * numChannels; + + // Calculate normalized coordinates for bilinear sampling + float roughness = 0.0f; + if (roughnessImage != nullptr) { + if (needsRoughnessResample) { + float u = (i % levelImage.width) / static_cast(levelImage.width); + float v = (i / levelImage.width) / static_cast(levelImage.height); + roughness = sampleBilinear(*roughnessImage, u, v, 0); + } else { + roughness = static_cast(roughnessImage->image[i]) / MAX_COLOR_VALUE; + } + } else { + if (m.roughness.value.IsHolding()) { + roughness = m.roughness.value.Get(); + } + } + + // Set anisotropy level (blue channel) + constructedAnisotropyImage.pixels[idxDst + 2] = + reverseASMLevel(levelImage.pixels[i], anisScale, roughness); + + // Reconstruct the red and green channels (from angle) + reverseCalculateASMImageRotation(angleImage.pixels[i], + anisRotation, + constructedAnisotropyImage.pixels[idxDst + 0], + constructedAnisotropyImage.pixels[idxDst + 1]); + } +} + +// Exports the anisotropy extension to a glTF material. +void +exportAnisotropyExtension( + ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const Material& m, + tinygltf::Material& gm, + std::unordered_map& constructedAnisotropyTextureCache) +{ + if (ctx.usd != nullptr) { + if (m.anisotropyLevel.value.IsEmpty() && m.anisotropyLevel.image < 0) { + if (m.anisotropyAngle.value.IsEmpty() && m.anisotropyAngle.image < 0) { + // Return if there is no anisotropy data so as to not write out an empty + // extension + return; + } + } + tinygltf::ExtensionMap ext; + float anisotropyLevelValue = 1.0f; + float anisotropyAngleValue = 0.0f; + float reconstructedStrength = 1.0f; + float reconstructedAngle = 0.0f; + bool hasAnisotropyLevelImage = false; + if (m.anisotropyLevel.value.IsHolding()) { + // Use default roughness if none is available + float roughness = + m.roughness.value.IsHolding() ? m.roughness.value.UncheckedGet() : 0.0f; + reconstructedStrength = + reverseASMLevel(m.anisotropyLevel.value.UncheckedGet(), 1.0f, roughness); + + addStrengthToAnisotropyExt(ext, reconstructedStrength); + } else if (m.anisotropyLevel.image >= 0) { + hasAnisotropyLevelImage = true; + } + + bool hasAnisotropyAngleImage = false; + if (m.anisotropyAngle.value.IsHolding()) { + reconstructedAngle = + reverseASMRotation(m.anisotropyAngle.value.UncheckedGet(), 0.0f); + addRotationToAnisotropyExt(ext, reconstructedAngle); + } else if (m.anisotropyAngle.image >= 0) { + hasAnisotropyAngleImage = true; + } + + // if both level and angle are stored in images, we need to construct a combined anisotropy + // image to export, as glTF only supports one texture for anisotropy + if (hasAnisotropyLevelImage || hasAnisotropyAngleImage) { + std::string anisLevelName = inputTranslator.getImageSourceName(m.anisotropyLevel.image); + if (extractAnisotropyParamsFromName( + anisLevelName, reconstructedStrength, reconstructedAngle)) { + addStrengthToAnisotropyExt(ext, reconstructedStrength); + + addRotationToAnisotropyExt(ext, reconstructedAngle); + } + std::string constructedTextureName = "anisotropyTexture_" + + std::to_string(m.anisotropyLevel.image) + "_" + + std::to_string(m.anisotropyAngle.image); + std::string constructedTextureUri = constructedTextureName + ".png"; + + ExportTextureCacheItem& exportTextureCacheItem = + constructedAnisotropyTextureCache[constructedTextureUri]; + + int textureIndex = -1; + int texCoord = -1; + if (exportTextureCacheItem.textureIndex < 0) { + Image constructedImage; + constructedImage.channels = 3; + + // If there is no anisotropy level or angle image, we will use a default 1x1 image + // with the value set to the non-image anisotropy data, so that we can still export + // the anisotropy extension and have it be read correctly by other applications, + // rather than just exporting an empty extension with no texture which would be + // ambiguous for a glTF importer to interpret + Image defaultAnisotropyLevelImage; + Image defaultAnisotropyAngleImage; + if (!hasAnisotropyLevelImage) { + defaultAnisotropyLevelImage.allocate(1, 1, 1); + defaultAnisotropyLevelImage.pixels[0] = anisotropyLevelValue; + } + if (!hasAnisotropyAngleImage) { + defaultAnisotropyAngleImage.allocate(1, 1, 1); + defaultAnisotropyAngleImage.pixels[0] = anisotropyAngleValue; + } + + // Decode level and angle images + auto decodedLevel = inputTranslator.getDecodedImage(m.anisotropyLevel.image); + auto decodedAngle = inputTranslator.getDecodedImage(m.anisotropyAngle.image); + Image& anisotropyLevelImage = hasAnisotropyLevelImage && decodedLevel.first + ? decodedLevel.second + : defaultAnisotropyLevelImage; + Image& anisotropyAngleImage = hasAnisotropyAngleImage && decodedAngle.first + ? decodedAngle.second + : defaultAnisotropyAngleImage; + + const tinygltf::Image* roughnessImage = nullptr; + if (gm.pbrMetallicRoughness.metallicRoughnessTexture.index >= 0 && + gm.pbrMetallicRoughness.metallicRoughnessTexture.index < + ctx.gltf->textures.size()) { + roughnessImage = + getImage(ctx.gltf, gm.pbrMetallicRoughness.metallicRoughnessTexture.index); + } + + constructAnisotropyImage(m, + anisotropyLevelImage, + anisotropyAngleImage, + reconstructedStrength, + reconstructedAngle, + roughnessImage, + constructedImage); + Input constructedAnisotropyInput; + constructedAnisotropyInput.image = + inputTranslator.addImage(std::move(constructedImage), + constructedTextureName, + constructedTextureUri, + ImageFormatPng, + false); + exportTexture(ctx, constructedAnisotropyInput, textureIndex, texCoord); + if (textureIndex != -1 && texCoord != -1) { + exportTextureCacheItem.textureIndex = textureIndex; + exportTextureCacheItem.texCoord = texCoord; + } + + } else { + textureIndex = exportTextureCacheItem.textureIndex; + texCoord = exportTextureCacheItem.texCoord; + } + if (textureIndex != -1 && texCoord != -1) { + addTextureToAnisotropyExt(ext, textureIndex, texCoord); + } + } + addMaterialExt(ctx, gm, "KHR_materials_anisotropy", ext); + } +} + +} // end namespace adobe::usd diff --git a/gltf/src/gltfAnisotropyASM.h b/gltf/src/gltfAnisotropyASM.h new file mode 100644 index 00000000..cbd1e427 --- /dev/null +++ b/gltf/src/gltfAnisotropyASM.h @@ -0,0 +1,49 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#pragma once +#include "gltfAnisotropy.h" +#include "gltfExport.h" +#include +#include + +namespace adobe::usd { + +// Gathers the anisotropy data from a glTF material and imports non-image ASM values. +bool +importAnisotropyData(ImportGltfContext& ctx, + const tinygltf::ExtensionMap& extensions, + const tinygltf::Value& anisoExt, + Material& m, + float roughness, + AnisotropyData& anisotropy, + Image& anisotropySrcImage); + +// Imports anisotropy textures from a glTF material and updates the USD material. +void +importAnisotropyTexture(ImportGltfContext& ctx, + const tinygltf::Material& gm, + Material& m, + float roughness, + const AnisotropyData& anisotropyData, + const Image& anisotropySrcImage, + std::unordered_map& cache); + +// Exports the anisotropy extension to a glTF material. +void +exportAnisotropyExtension( + ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const Material& m, + tinygltf::Material& gm, + std::unordered_map& constructedAnisotropyTextureCache); + +} // end namespace adobe::usd diff --git a/gltf/src/gltfAnisotropyOpenPBR.cpp b/gltf/src/gltfAnisotropyOpenPBR.cpp new file mode 100644 index 00000000..54f2d5d1 --- /dev/null +++ b/gltf/src/gltfAnisotropyOpenPBR.cpp @@ -0,0 +1,990 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#include "gltfAnisotropyOpenPBR.h" +#include "debugCodes.h" +#include "gltf.h" +#include "gltfImport.h" +#include +#include +#include + +#include + +using namespace PXR_NS; + +namespace adobe::usd { + +float +clamp01(float value) +{ + return std::min(1.0f, std::max(0.0f, value)); +} + +float +calculateOpenPBRImageRotation(float redChannelValue, float greenChannelValue, float rotation) +{ + // Convert channel values from [0, 1] to [-1, 1] + float x = redChannelValue * 2.0f - 1.0f; + float y = greenChannelValue * 2.0f - 1.0f; + + // Calculate the angle in radians and apply rotation + float angle = std::atan2(y, x) + rotation; + angle = std::fmod(angle, 2.0f * PI); + if (angle < 0.0f) + angle += 2.0f * PI; + return angle; +} + +// Calculates the OpenPBR roughness based on roughness and anisotropy strength. +std::pair +convertGltfRoughnessAnisotropyToOpenPBR(float roughness, float strength) +{ + if (strength <= 1e-7f) { + // If strength is near zero, we can skip calculations + return { roughness, 0.0f }; + } + // Step 1: Compute glTF alpha-roughness values + // glTF defines alpha = roughness^2 + float alpha = roughness * roughness; + float s2 = strength * strength; + float alpha_t = alpha * (1.0f - s2) + s2; // mix(alpha, 1.0, s^2) + float alpha_b = alpha; + + // Step 2: Derive OpenPBR specular_roughness_anisotropy (a) + // From the OpenPBR formulation: alpha_b / alpha_t = (1 - a) + // Guard against division by zero when alpha_t is near zero + float a_openpbr = 0.0f; + if (alpha_t > 1e-7f) { + a_openpbr = clamp01(1.0f - (alpha_b / alpha_t)); + } + + // Step 3: Derive OpenPBR specular_roughness (r_openpbr) + // OpenPBR's invariant: alpha_t^2 + alpha_b^2 = 2 * (r^2)^2 + // So: r = ( (alpha_t^2 + alpha_b^2) / 2 )^(1/4) + float alpha_t2 = alpha_t * alpha_t; + float alpha_b2 = alpha_b * alpha_b; + float r_openpbr = std::pow((alpha_t2 + alpha_b2) / 2.0f, 0.25f); + r_openpbr = clamp01(r_openpbr); + + return { r_openpbr, a_openpbr }; +} + +std::pair +convertOpenPBRRoughnessAnisotropyToGltf(float spRoughness, float spAnisotropy) +{ + float om = 1.0f - spAnisotropy; + float factor = std::sqrt(2.0f / (1.0f + om * om)); + float alpha_t = spRoughness * spRoughness * factor; + float alpha_b = alpha_t * om; + + float roughness = std::sqrt(clamp01(alpha_b)); + + float strength = 0.0f; + float d = 1.0f - alpha_b; + // If roughness is near zero, we can skip calculations + if (d > 1e-7f) { + // If roughness is near zero, we can skip calculations + strength = std::sqrt(clamp01((alpha_t - alpha_b) / d)); + } + + return { roughness, strength }; +} + +// Encode the arguments into the image name to generate a unique name for the OpenPBR roughness and +// anisotropyimages. +std::string +generateOpenPBRAnisotropyImageName(const std::string& prefix, + int roughnessIndex, + float roughness, + int anisotropyIndex, + float rotation) +{ + std::stringstream ss; + ss << std::fixed << std::setprecision(3); + ss << prefix << "_"; + if (roughnessIndex >= 0) + ss << roughnessIndex; + else + ss << "x"; + + ss << "_"; + if (roughness >= 0.0f) + ss << roughness; + else + ss << "x"; + + ss << "_"; + if (anisotropyIndex >= 0) + ss << anisotropyIndex; + else + ss << "x"; + + ss << "_"; + if (rotation != 0.0f) + ss << rotation; + else + ss << "x"; + + std::string result = ss.str(); + std::replace(result.begin(), result.end(), '.', '_'); + return result; +} + +std::string +generateOpenPBRAnisotropyTangentImageName(const std::string& prefix, + int anisotropyIndex, + float rotation) +{ + std::stringstream ss; + ss << std::fixed << std::setprecision(3); + ss << prefix << "_"; + if (anisotropyIndex >= 0) + ss << anisotropyIndex; + else + ss << "x"; + + ss << "_"; + if (rotation != 0.0f) + ss << rotation; + else + ss << "x"; + + std::string result = ss.str(); + std::replace(result.begin(), result.end(), '.', '_'); + return result; +} + +// Extracts the roughness value from a roughness image at a given UV coordinate. +float +extractRoughness(const Image& roughnessImage, + int roughnessChannel, + bool /*bilinear*/, + float u, + float v) +{ + // NOTE: we ignore bilinear sampling for now and just use nearest neighbor sampling + + // We expect/assume u and v to be in the range [0, 1) + + // Perform nearest-neighbor sampling to retrieve roughness + size_t linearX = static_cast(u * roughnessImage.width); + size_t linearY = static_cast(v * roughnessImage.height); + size_t linearIndex = + (linearY * roughnessImage.width + linearX) * roughnessImage.channels + roughnessChannel; + + // Guard against out-of-bounds access. + if (linearIndex >= roughnessImage.pixels.size()) { + TF_WARN("Linear index out of bounds in roughness image."); + return 0.0f; + } + + float roughness = roughnessImage.pixels[linearIndex]; + + return roughness; +} + +// Processes anisotropy pixels and populates anisotropy level and angle images. +void +processAnisotropyPixelsOpenPBR(const Image& gltfAnisotropyImage, + float roughness, + const Image* roughnessImage, + int roughnessChannel, + bool bilinearRoughnessSampling, + const AnisotropyData& anisotropyData, + Image* anisoRoughnessImage, + Image* anisoAnisotropyImage, + Image* anisoTangentImage) +{ + const size_t width = gltfAnisotropyImage.width; + const size_t height = gltfAnisotropyImage.height; + const size_t channels = gltfAnisotropyImage.channels; + + const float* srcPixels = gltfAnisotropyImage.pixels.data(); + float* roughnessPixels = nullptr; + float* anisotropyPixels = nullptr; + float* tangentPixels = nullptr; + + if (anisoRoughnessImage && anisoAnisotropyImage) { + anisoRoughnessImage->allocate(width, height, 1); + roughnessPixels = anisoRoughnessImage->pixels.data(); + + anisoAnisotropyImage->allocate(width, height, 1); + anisotropyPixels = anisoAnisotropyImage->pixels.data(); + } + + if (anisoTangentImage) { + anisoTangentImage->allocate(width, height, 3); + tangentPixels = anisoTangentImage->pixels.data(); + } + + const float strengthFactor = static_cast(anisotropyData.strength); + const float rotation = static_cast(anisotropyData.rotation); + bool applyRotation = rotation != 0.0f; + float cosRotation = std::cos(rotation); + float sinRotation = std::sin(rotation); + + for (size_t y = 0; y < height; ++y) { + const float ncy = static_cast(y) / height; + size_t index = y * width; + size_t srcIndex = index * channels; + size_t tanIndex = index * 3; + for (size_t x = 0; x < width; ++x) { + if (tangentPixels) { + float redChannelValue = srcPixels[srcIndex]; + float greenChannelValue = srcPixels[srcIndex + 1]; + if (applyRotation) { + // map from [0, 1] to [-1, 1] + redChannelValue = redChannelValue * 2.0f - 1.0f; + greenChannelValue = greenChannelValue * 2.0f - 1.0f; + float rotatedX = + redChannelValue * cosRotation - greenChannelValue * sinRotation; + float rotatedY = + redChannelValue * sinRotation + greenChannelValue * cosRotation; + // map back from [-1, 1] to [0, 1] + redChannelValue = (rotatedX + 1.0f) * 0.5f; + greenChannelValue = (rotatedY + 1.0f) * 0.5f; + } + tangentPixels[tanIndex] = redChannelValue; + tangentPixels[tanIndex + 1] = greenChannelValue; + // 0.5 results from mapping 0 in the range [-1, 1] to 0.5 in the range [0, 1] + tangentPixels[tanIndex + 2] = 0.5f; + tanIndex += 3; + } + if (roughnessPixels) { + float strength = srcPixels[srcIndex + 2] * strengthFactor; + + if (roughnessImage) { + const float ncx = static_cast(x) / width; + roughness = extractRoughness( + *roughnessImage, roughnessChannel, bilinearRoughnessSampling, ncx, ncy); + } + + // convert glTF anisotropy and roughness to OpenPBR anisotropy and roughness + auto [specular_roughness, specular_roughness_anisotropy] = + convertGltfRoughnessAnisotropyToOpenPBR(roughness, strength); + + roughnessPixels[index] = specular_roughness; + anisotropyPixels[index] = specular_roughness_anisotropy; + } + ++index; + srcIndex += channels; + } + } +} + +// Processes anisotropy pixels with roughness, populating the anisotropy level +void +processAnisotropyPixelsFromRoughnessOpenPBR(const AnisotropyData& anisotropyData, + const Image* roughnessImage, + int roughnessImageChannel, + Image& anisoRoughnessImage, + Image& anisoAnisotropyImage) +{ + if (!roughnessImage) { + return; + } + + const size_t width = roughnessImage->width; + const size_t height = roughnessImage->height; + const size_t channels = roughnessImage->channels; + + anisoRoughnessImage.allocate(width, height, 1); + anisoAnisotropyImage.allocate(width, height, 1); + float* roughnessPixels = anisoRoughnessImage.pixels.data(); + float* anisotropyPixels = anisoAnisotropyImage.pixels.data(); + + float strength = static_cast(anisotropyData.strength); + + for (size_t y = 0; y < height; ++y) { + size_t index = y * width; + size_t srcIndex = index * channels; + + for (size_t x = 0; x < width; ++x) { + float roughness = roughnessImage->pixels[srcIndex + roughnessImageChannel]; + + // convert glTF anisotropy and roughness to OpenPBR anisotropy and roughness + auto [specular_roughness, specular_roughness_anisotropy] = + convertGltfRoughnessAnisotropyToOpenPBR(roughness, strength); + + roughnessPixels[index] = specular_roughness; + anisotropyPixels[index] = specular_roughness_anisotropy; + + srcIndex += channels; + index++; + } + } +} + +GfVec3f +convertRotationToTangentOpenPBR(float rotation) +{ + // The tangent is a 3D vector where the x and y components encode the anisotropy rotation and + // the z component is set to 0.5 to represent a neutral value in the range [0, 1]. The + // rotation is mapped from [0, 2PI] to [0, 1] by taking the cosine and sine of the rotation + // angle, which gives us values in the range [-1, 1], and then remapping those values to the + // range [0, 1]. + float x = (std::cos(rotation) + 1.0f) / 2.0f; + float y = (std::sin(rotation) + 1.0f) / 2.0f; + return GfVec3f(x, y, 0.5f); +} + +void +importAnisotropyTextureOpenPBR(ImportGltfContext& ctx, + const tinygltf::Material& gm, + OpenPbrMaterial& m, + float roughness, + int roughnessTextureIndex, + const Image* roughnessImage, + int roughnessImageChannel, + const AnisotropyData& anisotropyData, + int anisotropyTextureIndex, + const Image& anisotropySrcImage, + std::unordered_map& cache) +{ + TF_DEBUG_MSG(FILE_FORMAT_GLTF, + "importAnisotropyTexture (OpenPBR) for material '%s'\n", + m.displayName.c_str()); + + bool hasAnisotropySrcImage = + anisotropySrcImage.width > 0 && anisotropySrcImage.height > 0 && anisotropyTextureIndex >= 0; + bool hasRoughnessImage = + roughnessImage != nullptr && roughnessImage->width > 0 && roughnessImage->height > 0; + + // if there is no anisotropy source image and no roughness image, then there is no anisotropy + if (!hasAnisotropySrcImage && !hasRoughnessImage) { + return; + } + + std::string anisoRoughnessCacheKey = + generateOpenPBRAnisotropyImageName("specularRoughness", + roughnessTextureIndex, + roughness, + anisotropyTextureIndex, + anisotropyData.rotation); + + std::string anisoAnisotropyCacheKey = + generateOpenPBRAnisotropyImageName("specularRoughnessAnisotropy", + roughnessTextureIndex, + roughness, + anisotropyTextureIndex, + anisotropyData.rotation); + + // The tangent cache key is encoded differently than roughness and anisotropy because the + // tangent image is only dependent on the anisotropy texture and rotation. + std::string anisoTangentCacheKey = generateOpenPBRAnisotropyTangentImageName( + "geometryTangent", anisotropyTextureIndex, anisotropyData.rotation); + + // Check if the conversion has already been done based on the cache keys. + int usdAnisoRoughnessImageIndex = lookupTexture(cache, anisoRoughnessCacheKey); + int usdAnisoAnisotropyImageIndex = lookupTexture(cache, anisoAnisotropyCacheKey); + int usdAnisoTangentImageIndex = lookupTexture(cache, anisoTangentCacheKey); + + // We expect either both roughness and anisotropy images to be present or both to be absent + // since they are derived from the same source image and parameters. If one is present without + // the other, it indicates an error in the caching logic or some other coding error. + if ((usdAnisoRoughnessImageIndex < 0 || usdAnisoAnisotropyImageIndex < 0) && + usdAnisoRoughnessImageIndex != usdAnisoAnisotropyImageIndex) { + TF_WARN("Error: Internal error with anisotropy image caching logic"); + return; + } + + Image anisoRoughnessImage; + Image anisoAnisotropyImage; + Image anisoTangentImage; + + // Get pointers to the images that need to be generated based on whether they are already in the + // cache. + Image* anisoRoughnessImagePtr = + usdAnisoRoughnessImageIndex < 0 ? &anisoRoughnessImage : nullptr; + Image* anisoAnisotropyImagePtr = + usdAnisoAnisotropyImageIndex < 0 ? &anisoAnisotropyImage : nullptr; + Image* anisoTangentImagePtr = usdAnisoTangentImageIndex < 0 ? &anisoTangentImage : nullptr; + + // Reserve space for new images in USD if needed to prevent vector from resizing during image + // addition + int numImagesToAdd = 0; + if (anisoRoughnessImagePtr) + ++numImagesToAdd; + if (anisoAnisotropyImagePtr) + ++numImagesToAdd; + if (anisoTangentImagePtr) + ++numImagesToAdd; + ctx.usd->reserveImages(numImagesToAdd); + + if (hasAnisotropySrcImage) { + if ((usdAnisoRoughnessImageIndex < 0 && usdAnisoAnisotropyImageIndex < 0) || + (usdAnisoTangentImageIndex < 0)) { + bool bilinearRoughnessSampling = + (hasRoughnessImage && (anisotropySrcImage.width != roughnessImage->width || + anisotropySrcImage.height != roughnessImage->height)); + + processAnisotropyPixelsOpenPBR(anisotropySrcImage, + roughness, + roughnessImage, + roughnessImageChannel, + bilinearRoughnessSampling, + anisotropyData, + anisoRoughnessImagePtr, + anisoAnisotropyImagePtr, + anisoTangentImagePtr); + } + } else if (hasRoughnessImage) { + if (usdAnisoRoughnessImageIndex < 0 && usdAnisoAnisotropyImageIndex < 0) { + processAnisotropyPixelsFromRoughnessOpenPBR(anisotropyData, + roughnessImage, + roughnessImageChannel, + anisoRoughnessImage, + anisoAnisotropyImage); + } + } + + if (anisoRoughnessImagePtr && usdAnisoRoughnessImageIndex < 0) { + usdAnisoRoughnessImageIndex = + cacheAndWriteImage(ctx, cache, anisoRoughnessCacheKey, anisoRoughnessImage); + } + if (anisoAnisotropyImagePtr && usdAnisoAnisotropyImageIndex < 0) { + usdAnisoAnisotropyImageIndex = + cacheAndWriteImage(ctx, cache, anisoAnisotropyCacheKey, anisoAnisotropyImage); + } + + // The tangent image is extracted from the anisotropy image's red and green channels and + // is independent of roughness and anisotropy level, so it only needs to be generated if + // it doesn't already exist in the cache. This allows us to avoid generating and caching + // a tangent image if it's not needed by the material. + if (anisoTangentImagePtr && usdAnisoTangentImageIndex < 0) { + usdAnisoTangentImageIndex = + cacheAndWriteImage(ctx, cache, anisoTangentCacheKey, anisoTangentImage); + } + if (usdAnisoRoughnessImageIndex >= 0 && usdAnisoAnisotropyImageIndex >= 0) { + setInputImage(m.specular_roughness, + usdAnisoRoughnessImageIndex, + anisotropyData.texture.texCoord, + AdobeTokens->r, + AdobeTokens->raw); + setInputImage(m.specular_roughness_anisotropy, + usdAnisoAnisotropyImageIndex, + anisotropyData.texture.texCoord, + AdobeTokens->r, + AdobeTokens->raw); + } + if (usdAnisoTangentImageIndex >= 0) { + setInputImage(m.geometry_tangent, + usdAnisoTangentImageIndex, + anisotropyData.texture.texCoord, + AdobeTokens->rgb, + AdobeTokens->raw); + } +} + +// OpenPbrMaterial overload for importAnisotropyData +void +importAnisotropyDataOpenPBR(ImportGltfContext& ctx, + const tinygltf::Material& gm, + const tinygltf::Value& anisoExt, + OpenPbrMaterial& m, + std::unordered_map& anisotropyTextureCache) +{ + TF_DEBUG_MSG( + FILE_FORMAT_GLTF, "importAnisotropyData (OpenPBR) for material '%s'\n", m.name.c_str()); + + AnisotropyData anisotropyData; + + // Read anisotropy strength value which defaults to 0.0 + readDoubleValue(anisoExt.Get("anisotropyStrength"), anisotropyData.strength); + float strengthFactor = static_cast(anisotropyData.strength); + + // If anisotropy strength is zero, ignore all anisotropy data since it will have no effect + // on the material. + if (strengthFactor <= 0.0f) { + return; + } + + readDoubleValue(anisoExt.Get("anisotropyRotation"), anisotropyData.rotation); + + // extract the roughness value or texture + float roughness = 0.0f; + Image roughnessSrcImage; + int roughnessImageChannel = -1; + int roughnessImageIndex = m.specular_roughness.image; + if (roughnessImageIndex >= 0) { + const ImageAsset& roughnessImageAsset = ctx.usd->images[m.specular_roughness.image]; + if (roughnessSrcImage.read(roughnessImageAsset)) { + roughnessImageChannel = token2Channel(m.specular_roughness.channel); + if (roughnessImageChannel >= roughnessSrcImage.channels) { + roughnessImageChannel = -1; + TF_WARN("Invalid roughness image channel for material '%s'", m.name.c_str()); + } + } else { + TF_WARN("Failed to read roughness image for material '%s'", m.name.c_str()); + return; + } + } else if (m.specular_roughness.value.IsHolding()) { + roughness = m.specular_roughness.value.UncheckedGet(); + } + + bool hasRoughnessImage = roughnessSrcImage.width > 0 && roughnessSrcImage.height > 0 && + roughnessImageChannel >= 0 && roughnessImageIndex >= 0; + + // get the gltf anisotropy texture info (if it exists) and read the image + readTextureInfo(anisoExt.Get("anisotropyTexture"), anisotropyData.texture); + + Image anisotropySrcImage; + Input anisotropyInput; + int anisotropyTextureIndex = anisotropyData.texture.index; + bool hasTexture = false; + if (anisotropyTextureIndex > -1) { + int imageIndex = importImage(ctx, anisotropyTextureIndex, m.name, "anisotropy"); + importTexture(ctx.gltf, + imageIndex, + anisotropyTextureIndex, + anisotropyData.texture.texCoord, + anisotropyInput, + AdobeTokens->rgb, + AdobeTokens->raw); + importTextureTransform(gm.extensions, anisotropyInput); + const ImageAsset& anisotropyImageAsset = ctx.usd->images[anisotropyInput.image]; + anisotropySrcImage.read(anisotropyImageAsset, anisotropySrcImage.channels); + + if (isSingleValueImage(anisotropySrcImage)) { + anisotropyData.strength = anisotropySrcImage.pixels[2] * strengthFactor; + + anisotropyData.rotation = calculateOpenPBRImageRotation( + anisotropySrcImage.pixels[0], anisotropySrcImage.pixels[1], anisotropyData.rotation); + } else { + hasTexture = true; + } + } + + // if there is no anisotropy texture and no roughness texture, we can convert the roughness and + // strength values to specular roughness and anisotropy values directly without needing to + // construct and sample from an anisotropy texture. + if (!hasTexture && !hasRoughnessImage) { + float strength = static_cast(anisotropyData.strength); + auto [specular_roughness, specular_roughness_anisotropy] = + convertGltfRoughnessAnisotropyToOpenPBR(roughness, strength); + + importValue1(m.specular_roughness, specular_roughness); + importValue1(m.specular_roughness_anisotropy, specular_roughness_anisotropy); + + if (anisotropyData.rotation != 0.0f) { + float angle = static_cast(anisotropyData.rotation); + m.geometry_tangent.value = convertRotationToTangentOpenPBR(angle); + } + + return; + } + + // If there is an anisotropy texture or roughness texture, we need to process them to generate + // OpenPBR roughness and anisotropy values + Image* roughnessImage = hasRoughnessImage ? &roughnessSrcImage : nullptr; + importAnisotropyTextureOpenPBR(ctx, + gm, + m, + roughness, + roughnessImageIndex, + roughnessImage, + roughnessImageChannel, + anisotropyData, + anisotropyTextureIndex, + anisotropySrcImage, + anisotropyTextureCache); +} + +bool +convertOpenPBRAnisotropyTexturesToGltf(ExportGltfContext& ctx, + tinygltf::Material& gm, + Image* specularRoughnessImage, + int specularRoughnessImageChannel, + Image* specularRoughnessAnisotropyImage, + int specularRoughnessAnisotropyImageChannel, + Image* geometryTangentImage, + float specularRoughnessValue, + float specularRoughnessAnisotropyValue, + Image& gltfAnisotropyImage, + Image& newRoughnessImage, + float& newRoughnessValue) +{ + if (gltfAnisotropyImage.width <= 0 || gltfAnisotropyImage.height <= 0) { + TF_WARN("Gltf Anisotropy image has invalid dimensions (%d, %d).", + gltfAnisotropyImage.width, + gltfAnisotropyImage.height); + return false; + } + + const size_t anisotropyWidth = static_cast(gltfAnisotropyImage.width); + const size_t anisotropyHeight = static_cast(gltfAnisotropyImage.height); + + if (geometryTangentImage && + (static_cast(geometryTangentImage->width) != anisotropyWidth || + static_cast(geometryTangentImage->height) != anisotropyHeight)) { + TF_WARN("Coding error: Geometry tangent image size does not match anisotropy image size. " + "Expected (%zu, " + "%zu) but got (%d, %d). The geometry tangent image will be ignored.", + anisotropyWidth, + anisotropyHeight, + geometryTangentImage->width, + geometryTangentImage->height); + return false; + } + + const bool hasSameSizedTextures = + (!specularRoughnessImage || + (static_cast(specularRoughnessImage->width) == anisotropyWidth && + static_cast(specularRoughnessImage->height) == anisotropyHeight)) && + (!specularRoughnessAnisotropyImage || + (static_cast(specularRoughnessAnisotropyImage->width) == anisotropyWidth && + static_cast(specularRoughnessAnisotropyImage->height) == anisotropyHeight)); + + float* srcRoughnessPixels = + specularRoughnessImage ? specularRoughnessImage->pixels.data() : nullptr; + int srcRoughnessChannels = specularRoughnessImage ? specularRoughnessImage->channels : 0; + float* srcAnisotropyPixels = + specularRoughnessAnisotropyImage ? specularRoughnessAnisotropyImage->pixels.data() : nullptr; + int srcAnisotropyChannels = + specularRoughnessAnisotropyImage ? specularRoughnessAnisotropyImage->channels : 0; + float* srcTangentPixels = geometryTangentImage ? geometryTangentImage->pixels.data() : nullptr; + + float* dstAnisoPixels = gltfAnisotropyImage.pixels.data(); + + float specularRoughness = specularRoughnessValue; + float specularRoughnessAnisotropy = specularRoughnessAnisotropyValue; + float strength = 1.0f; + + float minRoughnessValue = 1.0f; + float maxRoughnessValue = 0.0f; + + float* dstRoughnessPixels = nullptr; + bool hasRoughnessOrAnisotropyTexture = srcRoughnessPixels || srcAnisotropyPixels; + if (hasRoughnessOrAnisotropyTexture) { + newRoughnessImage.allocate(anisotropyWidth, anisotropyHeight, 1); + dstRoughnessPixels = newRoughnessImage.pixels.data(); + } else { + // if there is no roughness or anisotropy texture, we can convert the roughness and + // anisotropy values to a single roughness value without needing to construct and sample + // from a roughness texture. + auto [gltfRoughness, gltfStrength] = + convertOpenPBRRoughnessAnisotropyToGltf(specularRoughness, specularRoughnessAnisotropy); + + // we set the min and max roughness values to the same value to indicate that the roughness + // is constant across the image + minRoughnessValue = maxRoughnessValue = gltfRoughness; + strength = gltfStrength; + } + + auto sampleDecodedFloatImage = + [](const Image& image, size_t channel, float u, float v) -> float { + size_t srcX = + std::min(static_cast(u * image.width), static_cast(image.width) - 1); + size_t srcY = + std::min(static_cast(v * image.height), static_cast(image.height) - 1); + return image.pixels[(srcY * image.width + srcX) * image.channels + channel]; + }; + + for (size_t y = 0; y < anisotropyHeight; ++y) { + size_t pixelIndex = y * anisotropyWidth; + size_t dstIndex = pixelIndex * 3; + float ncy = static_cast(y) / anisotropyHeight; + for (size_t x = 0; x < anisotropyWidth; ++x) { + if (srcTangentPixels) { + dstAnisoPixels[dstIndex] = srcTangentPixels[dstIndex]; + dstAnisoPixels[dstIndex + 1] = srcTangentPixels[dstIndex + 1]; + } else { + dstAnisoPixels[dstIndex] = 1.0f; + dstAnisoPixels[dstIndex + 1] = 0.5f; + } + + if (hasRoughnessOrAnisotropyTexture) { + if (hasSameSizedTextures) { + if (specularRoughnessImage) { + specularRoughness = srcRoughnessPixels[pixelIndex * srcRoughnessChannels + + specularRoughnessImageChannel]; + } + if (specularRoughnessAnisotropyImage) { + specularRoughnessAnisotropy = + srcAnisotropyPixels[pixelIndex * srcAnisotropyChannels + + specularRoughnessAnisotropyImageChannel]; + } + } else { + float ncx = static_cast(x) / anisotropyWidth; + if (specularRoughnessImage) { + specularRoughness = sampleDecodedFloatImage( + *specularRoughnessImage, specularRoughnessImageChannel, ncx, ncy); + } + + if (specularRoughnessAnisotropyImage) { + specularRoughnessAnisotropy = + sampleDecodedFloatImage(*specularRoughnessAnisotropyImage, + specularRoughnessAnisotropyImageChannel, + ncx, + ncy); + } + } + + auto [gltfRoughness, gltfStrength] = convertOpenPBRRoughnessAnisotropyToGltf( + specularRoughness, specularRoughnessAnisotropy); + strength = gltfStrength; + dstRoughnessPixels[pixelIndex] = gltfRoughness; + + minRoughnessValue = std::min(minRoughnessValue, gltfRoughness); + maxRoughnessValue = std::max(maxRoughnessValue, gltfRoughness); + } + + dstAnisoPixels[dstIndex + 2] = strength; + ++pixelIndex; + dstIndex += 3; + } + } + + // if there is less that 1/255 difference in roughness values across the image, we can treat it + // as a constant roughness value and avoid writing out a roughness texture + const float roughnessEpsilon = 1.0f / 255.0f; + bool hasConstantRoughness = maxRoughnessValue - minRoughnessValue <= roughnessEpsilon; + if (hasConstantRoughness) { + newRoughnessValue = (minRoughnessValue + maxRoughnessValue) * 0.5f; + newRoughnessImage = Image(); + } else { + // set newRoughnessValue to an invalid value to indicate that the roughness is not constant + // across the image + newRoughnessValue = -1.0f; + } + + return true; +} + +// OpenPbrMaterial overload for exportAnisotropyExtension +void +exportAnisotropyExtensionOpenPBR( + ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm, + std::unordered_map& constructedAnisotropyTextureCache, + Input& newRoughnessInput) +{ + if (ctx.usd == nullptr) + return; + + // If there is no specular roughness anisotropy and no geometry tangent, then there is no + // anisotropy data to export, so we can return early to avoid writing out an empty + // extension. If there is a specular roughness texture/value, it can be encoded in the + // roughness value/texture of the glTF material + if (m.specular_roughness_anisotropy.value.IsEmpty() && + m.specular_roughness_anisotropy.image < 0 && m.geometry_tangent.value.IsEmpty() && + m.geometry_tangent.image < 0) { + return; + } + + tinygltf::ExtensionMap ext; + + // capture what textures are present + const bool hasSpecularRoughnessTexture = m.specular_roughness.image >= 0; + const bool hasSpecularRoughnessAnisotropyTexture = m.specular_roughness_anisotropy.image >= 0; + const bool hasGeometryTangentTexture = m.geometry_tangent.image >= 0; + + Image specularRoughnessImage; + int specularRoughnessImageChannel = -1; + if (hasSpecularRoughnessTexture) { + specularRoughnessImage = inputTranslator.getDecodedImage(m.specular_roughness.image).second; + if (specularRoughnessImage.width <= 0 || specularRoughnessImage.height <= 0) { + TF_WARN("Error obtaining specular roughness image."); + return; + } + specularRoughnessImageChannel = token2Channel(m.specular_roughness.channel); + if (specularRoughnessImageChannel < 0 || + specularRoughnessImageChannel >= specularRoughnessImage.channels) { + TF_WARN("Invalid specular roughness image channel for material '%s'", m.name.c_str()); + return; + } + } + + Image specularRoughnessAnisotropyImage; + int specularRoughnessAnisotropyImageChannel = -1; + if (hasSpecularRoughnessAnisotropyTexture) { + specularRoughnessAnisotropyImage = + inputTranslator.getDecodedImage(m.specular_roughness_anisotropy.image).second; + if (specularRoughnessAnisotropyImage.width <= 0 || + specularRoughnessAnisotropyImage.height <= 0) { + TF_WARN("Error obtaining specular roughness anisotropy image."); + return; + } + specularRoughnessAnisotropyImageChannel = + token2Channel(m.specular_roughness_anisotropy.channel); + if (specularRoughnessAnisotropyImageChannel < 0 || + specularRoughnessAnisotropyImageChannel >= specularRoughnessAnisotropyImage.channels) { + TF_WARN("Invalid specular roughness anisotropy image channel for material '%s'", + m.name.c_str()); + return; + } + } + + Image geometryTangentImage; + if (hasGeometryTangentTexture) { + geometryTangentImage = inputTranslator.getDecodedImage(m.geometry_tangent.image).second; + if (geometryTangentImage.width <= 0 || geometryTangentImage.height <= 0) { + TF_WARN("Error obtaining geometry tangent image."); + return; + } + if (geometryTangentImage.channels < 3) { + TF_WARN("Geometry tangent image must have at least 3 channels for material '%s'", + m.name.c_str()); + return; + } + } + + // extract the fixed specular roughness and specular roughness anisotropy values from the + // material (if present) + const float specularRoughnessValue = m.specular_roughness.value.IsHolding() + ? m.specular_roughness.value.UncheckedGet() + : 0.0f; + const float specularRoughnessAnisotropyValue = + m.specular_roughness_anisotropy.value.IsHolding() + ? m.specular_roughness_anisotropy.value.UncheckedGet() + : 0.0f; + + // extract the anisotropy rotation from the geometry tangent if it exists + float gltfAnisotropyRotation = 0.0f; + if (!hasGeometryTangentTexture && m.geometry_tangent.value.IsHolding()) { + GfVec3f tangentValue = m.geometry_tangent.value.UncheckedGet(); + gltfAnisotropyRotation = + calculateOpenPBRImageRotation(tangentValue[0], tangentValue[1], 0.0f); + } + + if (gltfAnisotropyRotation != 0.0f) { + addRotationToAnisotropyExt(ext, gltfAnisotropyRotation); + } + + // handle simple case where specular_roughness, specular_roughness_anisotropy and + // geometry_tangent are values (no textures) + if (!hasSpecularRoughnessTexture && !hasSpecularRoughnessAnisotropyTexture && + !hasGeometryTangentTexture) { + auto [gltfRoughness, gltfStrength] = convertOpenPBRRoughnessAnisotropyToGltf( + specularRoughnessValue, specularRoughnessAnisotropyValue); + gm.pbrMetallicRoughness.roughnessFactor = gltfRoughness; + if (gltfStrength != 0.0f) { + addStrengthToAnisotropyExt(ext, gltfStrength); + } + + addMaterialExt(ctx, gm, "KHR_materials_anisotropy", ext); + return; + } + + // handle the complex case where one or more of specular_roughness, + // specular_roughness_anisotropy and geometry_tangent are textures. + + // Prefer the geometry tangent texture's resolution for the anisotropy texture since it + // encodes the anisotropy rotation which is the most visually impactful aspect of + // anisotropy. Another reason is that it's more complicated to sample the geometry tangent + // texture. If there is no geometry tangent texture, use the maximum resolution between the + // roughness and anisotropy textures to preserve as much detail as possible in both maps. + int anisotropyWidth = + hasGeometryTangentTexture + ? geometryTangentImage.width + : std::max(specularRoughnessImage.width, specularRoughnessAnisotropyImage.width); + int anisotropyHeight = + hasGeometryTangentTexture + ? geometryTangentImage.height + : std::max(specularRoughnessImage.height, specularRoughnessAnisotropyImage.height); + if (anisotropyWidth <= 0 || anisotropyHeight <= 0) { + TF_WARN("Error obtaining anisotropy images."); + return; + } + + // generate a cache key for the constructed anisotropy texture based on the parameters that + // affect its construction. This is used to avoid redundant construction and export of the same + // anisotropy texture for materials that share the same anisotropy parameters and source + // textures. + std::stringstream textureKeyStream; + textureKeyStream << std::fixed << std::setprecision(6) << m.specular_roughness.image << "_" + << m.specular_roughness_anisotropy.image << "_" << m.geometry_tangent.image + << "_" << specularRoughnessValue << "_" << specularRoughnessAnisotropyValue + << "_" << gltfAnisotropyRotation; + std::string textureKeySuffix = textureKeyStream.str(); + + std::string anisotropyTextureName = "anisotropyTexture_" + textureKeySuffix; + std::string anisotropyTextureUri = anisotropyTextureName + ".png"; + ExportTextureCacheItem& constructedAnisotropyTextureCacheItem = + constructedAnisotropyTextureCache[anisotropyTextureUri]; + + int textureIndex = -1; + int texCoord = -1; + if (constructedAnisotropyTextureCacheItem.textureIndex < 0) { + // if (constructedAnisotropyInput.image < 0) { + // create gltf anisotropyTexture + Image gltfAnisotropyImage; + gltfAnisotropyImage.allocate(anisotropyWidth, anisotropyHeight, 3); + Image newRoughnessImage; + Input constructedAnisotropyInput; + float newRoughnessValue = -1.0f; + + if (convertOpenPBRAnisotropyTexturesToGltf( + ctx, + gm, + hasSpecularRoughnessTexture ? &specularRoughnessImage : nullptr, + specularRoughnessImageChannel, + hasSpecularRoughnessAnisotropyTexture ? &specularRoughnessAnisotropyImage : nullptr, + specularRoughnessAnisotropyImageChannel, + hasGeometryTangentTexture ? &geometryTangentImage : nullptr, + specularRoughnessValue, + specularRoughnessAnisotropyValue, + gltfAnisotropyImage, + newRoughnessImage, + newRoughnessValue)) { + constructedAnisotropyInput.image = + inputTranslator.addImage(std::move(gltfAnisotropyImage), + anisotropyTextureName, + anisotropyTextureUri, + ImageFormatPng, + false); + } else { + TF_WARN("Error constructing anisotropy texture for material '%s'.", + m.displayName.c_str()); + return; + } + + exportTexture(ctx, constructedAnisotropyInput, textureIndex, texCoord); + if (textureIndex != -1 && texCoord != -1) { + constructedAnisotropyTextureCacheItem.textureIndex = textureIndex; + constructedAnisotropyTextureCacheItem.texCoord = texCoord; + } + + if (newRoughnessImage.width > 0 && newRoughnessImage.height > 0) { + std::string roughnessTextureName = "roughnessTexture_" + textureKeySuffix; + std::string roughnessTextureUri = roughnessTextureName + ".png"; + newRoughnessInput = m.specular_roughness; + newRoughnessInput.image = inputTranslator.addImage(std::move(newRoughnessImage), + roughnessTextureName, + roughnessTextureUri, + ImageFormatPng, + true); + newRoughnessInput.channel = AdobeTokens->r; + } else if (newRoughnessValue >= 0.0f) { + newRoughnessInput.value = VtValue(newRoughnessValue); + } + } else { + textureIndex = constructedAnisotropyTextureCacheItem.textureIndex; + texCoord = constructedAnisotropyTextureCacheItem.texCoord; + } + + if (textureIndex != -1 && texCoord != -1) { + addTextureToAnisotropyExt(ext, textureIndex, texCoord); + } + + // Since we have a texture, we need to set the strength factor to the extension to 1.0. + // Othersize, the default value of 0.0 would be used and would cause the anisotropy to be + // disabled. + + addStrengthToAnisotropyExt(ext, 1.0f); + + addMaterialExt(ctx, gm, "KHR_materials_anisotropy", ext); +} + +} // end namespace adobe::usd diff --git a/gltf/src/gltfAnisotropyOpenPBR.h b/gltf/src/gltfAnisotropyOpenPBR.h new file mode 100644 index 00000000..012bbf3b --- /dev/null +++ b/gltf/src/gltfAnisotropyOpenPBR.h @@ -0,0 +1,38 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#pragma once +#include "gltfAnisotropy.h" +#include "gltfExport.h" +#include +#include + +namespace adobe::usd { + +// Imports anisotropy data from a glTF material into an OpenPBR material. +void +importAnisotropyDataOpenPBR(ImportGltfContext& ctx, + const tinygltf::Material& gm, + const tinygltf::Value& anisoExt, + OpenPbrMaterial& m, + std::unordered_map& anisotropyTextureCache); + +// Exports the anisotropy extension to a glTF material from an OpenPBR material. +void +exportAnisotropyExtensionOpenPBR( + ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm, + std::unordered_map& constructedAnisotropyTextureCache, + Input& newRoughnessInput); + +} // end namespace adobe::usd diff --git a/gltf/src/gltfExport.cpp b/gltf/src/gltfExport.cpp index d586b5b7..0fa5ed79 100644 --- a/gltf/src/gltfExport.cpp +++ b/gltf/src/gltfExport.cpp @@ -11,9 +11,11 @@ governing permissions and limitations under the License. */ #include "gltfExport.h" #include "debugCodes.h" -#include "gltfAnisotropy.h" +#include "gltfAnisotropyASM.h" +#include "gltfAnisotropyOpenPBR.h" #include #include +#include #include #include #include @@ -43,6 +45,8 @@ using namespace PXR_NS; namespace adobe::usd { +constexpr float kClampEpsilon = 1.0e-6f; + void addExtension(ExportGltfContext& ctx, tinygltf::ExtensionMap& extensionMap, @@ -62,7 +66,7 @@ exportAnimationTracks(ExportGltfContext& ctx) { if (ctx.usd->hasAnimations) { ctx.gltf->animations.resize(ctx.usd->animationTracks.size()); - for (int animationTrackIndex = 0; animationTrackIndex < ctx.usd->animationTracks.size(); + for (size_t animationTrackIndex = 0; animationTrackIndex < ctx.usd->animationTracks.size(); animationTrackIndex++) { const AnimationTrack& track = ctx.usd->animationTracks[animationTrackIndex]; ctx.gltf->animations[animationTrackIndex].name = getNodeName(track); @@ -187,10 +191,20 @@ exportLightExtension(ExportGltfContext& ctx, int lightIndex, ExtMap& extensions) bool exportLights(ExportGltfContext& ctx) { - ctx.gltf->lights.resize(ctx.usd->lights.size()); + ctx.gltf->lights.reserve(ctx.usd->lights.size()); for (size_t i = 0; i < ctx.usd->lights.size(); ++i) { const Light& light = ctx.usd->lights[i]; - tinygltf::Light& gltfLight = ctx.gltf->lights[i]; + + if (light.type == LightType::Environment) { + // TODO: Export environment lights using the EXT_lights_image_based extension + // https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Vendor/EXT_lights_image_based + TF_WARN("Skipping environment light '%s': not supported by glTF", light.name.c_str()); + continue; + } + + ctx.usdLightIndexToGltfLightIndexMap[i] = ctx.gltf->lights.size(); + ctx.gltf->lights.emplace_back(); + tinygltf::Light& gltfLight = ctx.gltf->lights.back(); float radius = light.radius; GfVec2f length = light.length; @@ -226,7 +240,7 @@ exportLights(ExportGltfContext& ctx) // Use the fraction of the cone containing the falloff to calculate the inner cone gltfLight.spot.innerConeAngle = - (1 - ctx.usd->lights[i].coneFalloff) * gltfLight.spot.outerConeAngle; + (1 - light.coneFalloff) * gltfLight.spot.outerConeAngle; // inner cone angle must always be less than outer cone angle, according to the // glTF spec. If it isn't, set it to be just less than the outer cone angle @@ -263,8 +277,6 @@ exportLights(ExportGltfContext& ctx) intensity *= GLTF_POINT_LIGHT_INTENSITY_MULT; - // TODO: Address environment lights separately - break; } @@ -488,13 +500,19 @@ exportNode(ExportGltfContext& ctx, int usdNodeIndex, int offset) addExtension(ctx, gnode.extensions, getNerfExtString(), nerfExt, true); } if (node.light != -1) { - gnode.light = node.light; - - // Add the extension info to the node indicating that it has a light. This ensures that the - // lights extension is properly added as a required extension - tinygltf::Value::Object lightExt; - exportLightExtension(ctx, node.light, lightExt); - addExtension(ctx, gnode.extensions, "KHR_lights_punctual", lightExt, true); + auto it = ctx.usdLightIndexToGltfLightIndexMap.find(static_cast(node.light)); + + // USD lights that aren't supported in glTF will not be in this list + if (it != ctx.usdLightIndexToGltfLightIndexMap.end()) { + int gltfLightIndex = static_cast(it->second); + gnode.light = gltfLightIndex; + + // Add the extension info to the node indicating that it has a light. This ensures that + // the lights extension is properly added as a required extension + tinygltf::Value::Object lightExt; + exportLightExtension(ctx, gltfLightIndex, lightExt); + addExtension(ctx, gnode.extensions, "KHR_lights_punctual", lightExt, true); + } } if (node.staticMeshes.size()) { // Skinned meshes are written in exportSkeletons, process only staticMeshes here. @@ -939,7 +957,9 @@ addMaterialExt(ExportGltfContext& ctx, const std::string& extensionName, const ExtMap& ext) { - addExtension(ctx, gltfMaterial.extensions, extensionName, ext); + if (!ext.empty()) { + addExtension(ctx, gltfMaterial.extensions, extensionName, ext); + } } void @@ -1105,6 +1125,223 @@ addTextureToExt(ExportGltfContext& ctx, return false; } +bool +getBoolInputValue(const Input& input, bool defaultValue = false) +{ + if (input.value.IsHolding()) { + return input.value.UncheckedGet(); + } + if (input.value.IsHolding()) { + return input.value.UncheckedGet() != 0; + } + if (input.value.IsHolding()) { + return input.value.UncheckedGet() != 0.0f; + } + return defaultValue; +} + +float +getFloatInputValue(const Input& input, float defaultValue = 0.0f) +{ + if (input.value.IsHolding()) { + const float value = input.value.UncheckedGet(); + return input.scale[0] * value + input.bias[0]; + } + if (input.value.IsHolding()) { + const float value = static_cast(input.value.UncheckedGet()); + return input.scale[0] * value + input.bias[0]; + } + return defaultValue; +} + +bool +isEnabledInput(const Input& input) +{ + return !input.isEmpty() && !input.isZeroInput(); +} + +Input +scaleFloatInput(const Input& input, float factor) +{ + Input scaled = input; + scaled.scale *= factor; + scaled.bias *= factor; + return scaled; +} + +struct VolumeMedium +{ + GfVec3f extinctionCoefficient = GfVec3f(0.0f); + GfVec3f multiscatterAlbedo = GfVec3f(0.0f); + float anisotropy = 0.0f; +}; + +float +getBlendWeight(const Input& input) +{ + if (input.image >= 0) { + return 1.0f; + } + + float value = 0.0f; + return getInputValue(input, &value) ? std::clamp(value, 0.0f, 1.0f) : 0.0f; +} + +GfVec3f +clampColor01(const GfVec3f& value) +{ + return GfVec3f(std::clamp(value[0], 0.0f, 1.0f), + std::clamp(value[1], 0.0f, 1.0f), + std::clamp(value[2], 0.0f, 1.0f)); +} + +bool +resolveTransmissionVolumeMedium(const OpenPbrMaterial& m, VolumeMedium* medium) +{ + float transmissionDepth = 0.0f; + if (!getInputValue(m.transmission_depth, &transmissionDepth) || transmissionDepth <= 0.0f) { + return false; + } + + GfVec3f transmissionColor(1.0f); + if (!m.transmission_color.isEmpty() && + !getInputValue(m.transmission_color, &transmissionColor)) { + return false; + } + + GfVec3f singleScatterAlbedo(0.0f); + if (!m.transmission_scatter.isEmpty() && m.transmission_scatter.image < 0 && + !getInputValue(m.transmission_scatter, &singleScatterAlbedo)) { + return false; + } + + float anisotropy = 0.0f; + if (!m.transmission_scatter_anisotropy.isEmpty() && + !getInputValue(m.transmission_scatter_anisotropy, &anisotropy)) { + return false; + } + anisotropy = std::clamp(anisotropy, -1.0f, 1.0f); + + transmissionColor = GfVec3f(std::clamp(transmissionColor[0], kClampEpsilon, 1.0f), + std::clamp(transmissionColor[1], kClampEpsilon, 1.0f), + std::clamp(transmissionColor[2], kClampEpsilon, 1.0f)); + singleScatterAlbedo = clampColor01(singleScatterAlbedo); + + medium->extinctionCoefficient = GfVec3f(-std::log(transmissionColor[0]) / transmissionDepth, + -std::log(transmissionColor[1]) / transmissionDepth, + -std::log(transmissionColor[2]) / transmissionDepth); + medium->multiscatterAlbedo = singleScatterToMultiscatter(singleScatterAlbedo, anisotropy); + medium->anisotropy = anisotropy; + return true; +} + +bool +resolveSubsurfaceVolumeMedium(const OpenPbrMaterial& m, VolumeMedium* medium) +{ + float subsurfaceRadius = 0.0f; + if (!getInputValue(m.subsurface_radius, &subsurfaceRadius) || subsurfaceRadius <= 0.0f) { + return false; + } + + GfVec3f subsurfaceRadiusScale(1.0f); + if (!m.subsurface_radius_scale.isEmpty() && + !getInputValue(m.subsurface_radius_scale, &subsurfaceRadiusScale)) { + return false; + } + + GfVec3f multiscatterAlbedo(0.0f); + if (!m.subsurface_color.isEmpty() && m.subsurface_color.image < 0 && + !getInputValue(m.subsurface_color, &multiscatterAlbedo)) { + return false; + } + + float anisotropy = 0.0f; + if (!m.subsurface_scatter_anisotropy.isEmpty() && + !getInputValue(m.subsurface_scatter_anisotropy, &anisotropy)) { + return false; + } + + const GfVec3f mfp = GfCompMult(GfVec3f(subsurfaceRadius), + GfVec3f(std::max(subsurfaceRadiusScale[0], kClampEpsilon), + std::max(subsurfaceRadiusScale[1], kClampEpsilon), + std::max(subsurfaceRadiusScale[2], kClampEpsilon))); + + medium->extinctionCoefficient = GfVec3f(1.0f / std::max(mfp[0], kClampEpsilon), + 1.0f / std::max(mfp[1], kClampEpsilon), + 1.0f / std::max(mfp[2], kClampEpsilon)); + medium->multiscatterAlbedo = clampColor01(multiscatterAlbedo); + medium->anisotropy = std::clamp(anisotropy, -1.0f, 1.0f); + return true; +} + +bool +resolveVolumeMedium(const OpenPbrMaterial& m, VolumeMedium* medium) +{ + // TODO this calculation should be done at a per-texel level if textures exist + const float transmissionWeight = getBlendWeight(m.transmission_weight); + const float subsurfaceWeight = getBlendWeight(m.subsurface_weight); + + VolumeMedium transmissionMedium; + const bool hasTransmissionMedium = + transmissionWeight > 0.0f && resolveTransmissionVolumeMedium(m, &transmissionMedium); + + VolumeMedium subsurfaceMedium; + const bool hasSubsurfaceMedium = + subsurfaceWeight > 0.0f && resolveSubsurfaceVolumeMedium(m, &subsurfaceMedium); + + float transmissionContribution = hasTransmissionMedium ? transmissionWeight : 0.0f; + float subsurfaceContribution = + hasSubsurfaceMedium ? subsurfaceWeight * (1.0f - transmissionContribution) : 0.0f; + + if (transmissionContribution <= 0.0f && subsurfaceContribution <= 0.0f) { + return false; + } + + if (subsurfaceContribution <= 0.0f) { + *medium = transmissionMedium; + return true; + } + + if (transmissionContribution <= 0.0f) { + *medium = subsurfaceMedium; + return true; + } + + const float total = transmissionContribution + subsurfaceContribution; + const float transmissionMix = transmissionContribution / total; + const float subsurfaceMix = subsurfaceContribution / total; + medium->extinctionCoefficient = transmissionMix * transmissionMedium.extinctionCoefficient + + subsurfaceMix * subsurfaceMedium.extinctionCoefficient; + medium->multiscatterAlbedo = + clampColor01(transmissionMix * transmissionMedium.multiscatterAlbedo + + subsurfaceMix * subsurfaceMedium.multiscatterAlbedo); + medium->anisotropy = std::clamp(transmissionMix * transmissionMedium.anisotropy + + subsurfaceMix * subsurfaceMedium.anisotropy, + -0.9999f, + 0.9999f); + return true; +} + +bool +resolveAttenuationFromMedium(const VolumeMedium& medium, + float* attenuationDistance, + GfVec3f* attenuationColor) +{ + const GfVec3f& extinction = medium.extinctionCoefficient; + if (extinction[0] <= 0.0f && extinction[1] <= 0.0f && extinction[2] <= 0.0f) { + return false; + } + + const GfVec3f mfp(1.0f / std::max(extinction[0], kClampEpsilon), + 1.0f / std::max(extinction[1], kClampEpsilon), + 1.0f / std::max(extinction[2], kClampEpsilon)); + *attenuationDistance = std::max(mfp[0], std::max(mfp[1], mfp[2])); + *attenuationColor = clampColor01(GfVec3f(std::exp(-extinction[0] * (*attenuationDistance)), + std::exp(-extinction[1] * (*attenuationDistance)), + std::exp(-extinction[2] * (*attenuationDistance)))); + return true; +} + bool exportUnlitExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1119,6 +1356,20 @@ exportUnlitExtension(ExportGltfContext& ctx, return false; } +bool +exportUnlitExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (m.isUnlit) { + addMaterialExt(ctx, gm, "KHR_materials_unlit", ext); + return true; + } + return false; +} + bool exportClearcoatExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1142,6 +1393,30 @@ exportClearcoatExtension(ExportGltfContext& ctx, return false; } +bool +exportClearcoatExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addTextureToExt( + ctx, inputTranslator, ext, m.coat_weight, "clearcoatTexture", "clearcoatFactor") | + addTextureToExt(ctx, + inputTranslator, + ext, + m.coat_roughness, + "clearcoatRoughnessTexture", + "clearcoatRoughnessFactor") | + addTextureToExt( + ctx, inputTranslator, ext, m.geometry_coat_normal, "clearcoatNormalTexture")) { + addMaterialExt(ctx, gm, "KHR_materials_clearcoat", ext); + return true; + } + + return false; +} + bool exportCoatExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1167,6 +1442,65 @@ exportCoatExtension(ExportGltfContext& ctx, return false; } +bool +exportCoatExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (!addTextureToExt(ctx, inputTranslator, ext, m.coat_weight, "coatTexture", "coatFactor")) { + return false; + } + bool hasAdvancedCoatData = false; + + hasAdvancedCoatData |= addTextureToExt( + ctx, inputTranslator, ext, m.coat_color, "coatColorTexture", "coatColorFactor", 1.0f); + hasAdvancedCoatData |= addTextureToExt(ctx, + inputTranslator, + ext, + m.coat_roughness_anisotropy, + "coatAnisotropyTexture", + "coatAnisotropyStrength", + 0.0f); + + // The coat IOR and darkening default values in OpenPBR are different than the defaults in the + // simple glTF clearcoat extension, so we need to check if the values are different than the + // defaults before deciding whether to add the advanced coat extension or not. + if (m.coat_ior.value.IsHolding()) { + float value = m.coat_ior.value.UncheckedGet(); + hasAdvancedCoatData |= value != 1.5f; // clearcoat default is 1.5 but coat default is 1.6 + addFloatValueToExt(ext, "coatIor", m.coat_ior.value, 1.6f); + } else { + hasAdvancedCoatData = true; + } + + if (m.coat_darkening.value.IsHolding()) { + float value = m.coat_darkening.value.UncheckedGet(); + hasAdvancedCoatData |= value != 0.0f; // clearcoat default is 0.0 but coat default is 1.0 + addFloatValueToExt(ext, "coatDarkeningFactor", m.coat_darkening.value, 1.0f); + } else { + hasAdvancedCoatData = true; + } + + // If we don't actually have any data that requires the advanced coat extension, + // then we can just export the simple clearcoat extension which has better compatibility + // with existing glTF viewers. + if (hasAdvancedCoatData) { + addTextureToExt(ctx, + inputTranslator, + ext, + m.coat_roughness, + "coatRoughnessTexture", + "coatRoughnessFactor"); + addTextureToExt(ctx, inputTranslator, ext, m.geometry_coat_normal, "coatNormalTexture"); + addMaterialExt(ctx, gm, "KHR_materials_coat", ext); + return true; + } + + return false; +} + bool exportEmissiveStrengthExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1196,6 +1530,20 @@ exportIorExtension(ExportGltfContext& ctx, return false; } +bool +exportIorExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addFloatValueToExt(ext, "ior", m.specular_ior.value, 1.5f)) { + addMaterialExt(ctx, gm, "KHR_materials_ior", ext); + return true; + } + return false; +} + bool exportSheenExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1218,6 +1566,161 @@ exportSheenExtension(ExportGltfContext& ctx, return false; } +bool +exportSheenExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addTextureToExt( + ctx, inputTranslator, ext, m.fuzz_color, "sheenColorTexture", "sheenColorFactor") | + addTextureToExt(ctx, + inputTranslator, + ext, + m.fuzz_roughness, + "sheenRoughnessTexture", + "sheenRoughnessFactor")) { + addMaterialExt(ctx, gm, "KHR_materials_sheen", ext); + return true; + } + + return false; +} + +bool +exportDiffuseRoughnessExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addTextureToExt(ctx, + inputTranslator, + ext, + m.base_diffuse_roughness, + "diffuseRoughnessTexture", + "diffuseRoughnessFactor", + 0.0f)) { + addMaterialExt(ctx, gm, "KHR_materials_diffuse_roughness", ext); + return true; + } + + return false; +} + +bool +exportFuzzExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addTextureToExt( + ctx, inputTranslator, ext, m.fuzz_weight, "fuzzTexture", "fuzzFactor", 0.0f)) { + addTextureToExt( + ctx, inputTranslator, ext, m.fuzz_color, "fuzzColorTexture", "fuzzColorFactor", 1.0f); + addTextureToExt(ctx, + inputTranslator, + ext, + m.fuzz_roughness, + "fuzzRoughnessTexture", + "fuzzRoughnessFactor", + 0.5f); + addMaterialExt(ctx, gm, "KHR_materials_fuzz", ext); + return true; + } + + return false; +} + +bool +exportIridescenceExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + const Input thicknessInNanometers = scaleFloatInput(m.thin_film_thickness, 1000.0f); + if (addTextureToExt(ctx, + inputTranslator, + ext, + m.thin_film_weight, + "iridescenceTexture", + "iridescenceFactor", + 0.0f)) { + addTextureToExt(ctx, + inputTranslator, + ext, + thicknessInNanometers, + "iridescenceThicknessTexture", + "iridescenceThicknessMaximum", + 400.0f); + addFloatValueToExt(ext, "iridescenceIor", m.thin_film_ior.value, 1.3f); + addMaterialExt(ctx, gm, "KHR_materials_iridescence", ext); + return true; + } + + return false; +} + +bool +exportDiffuseTransmissionExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm, + bool thinWalled) +{ + ExtMap ext; + if (addTextureToExt(ctx, + inputTranslator, + ext, + m.subsurface_weight, + "diffuseTransmissionTexture", + "diffuseTransmissionFactor", + 0.0f)) { + // subsurface_color maps to diffuseTransmissionColor only in the thin-walled case. + // For volumetric materials the scattering color is handled by the volume scatter extension. + if (thinWalled) { + addTextureToExt(ctx, + inputTranslator, + ext, + m.subsurface_color, + "diffuseTransmissionColorTexture", + "diffuseTransmissionColorFactor", + 1.0f); + } + addMaterialExt(ctx, gm, "KHR_materials_diffuse_transmission", ext); + return true; + } + + return false; +} + +bool +exportDispersionExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + if (!isEnabledInput(m.transmission_weight)) { + return false; + } + + ExtMap ext; + constexpr float kDefaultAbbeNumber = 20.0f; + const float dispersionScale = getFloatInputValue(m.transmission_dispersion_scale); + const float abbeNumber = + getFloatInputValue(m.transmission_dispersion_abbe_number, kDefaultAbbeNumber); + if (dispersionScale != 0.0f && abbeNumber > 0.0f) { + addFloatValueToExt(ext, "dispersion", dispersionScale * (kDefaultAbbeNumber / abbeNumber)); + addMaterialExt(ctx, gm, "KHR_materials_dispersion", ext); + return true; + } + + return false; +} + bool exportSpecularExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1250,6 +1753,38 @@ exportSpecularExtension(ExportGltfContext& ctx, return false; } +bool +exportSpecularExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addTextureToExt( + ctx, inputTranslator, ext, m.specular_weight, "specularTexture", "specularFactor", 1.0f) | + addTextureToExt(ctx, + inputTranslator, + ext, + m.specular_color, + "specularColorTexture", + "specularColorFactor", + 1.0f)) { + // We will always add the EXT_materials_specular_edge_color sub-extension to tell + // glTF loaders that this material can be interpreted using the ASM/OpenPBR specular model. + std::map extensions; + std::map extObj; + // Empty objects seem to be serialized as null in glTF, so we need to add a dummy value for + // now. + extObj["specularEdgeColorEnabled"] = tinygltf::Value(true); + addExtension(ctx, extensions, "EXT_materials_specular_edge_color", extObj, false); + ext["extensions"] = tinygltf::Value(extensions); + addMaterialExt(ctx, gm, "KHR_materials_specular", ext); + return true; + } + + return false; +} + bool exportTransmissionExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1271,6 +1806,32 @@ exportTransmissionExtension(ExportGltfContext& ctx, return false; } +bool +exportTransmissionExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addTextureToExt(ctx, + inputTranslator, + ext, + m.transmission_weight, + "transmissionTexture", + "transmissionFactor")) { + // If no transmission factor was associated with the input, we author a factor of 1.0 to + // enable the extension + if (!ext.count("transmissionFactor")) { + addFloatValueToExt(ext, "transmissionFactor", 1.0f); + } + addMaterialExt(ctx, gm, "KHR_materials_transmission", ext); + + return true; + } + + return false; +} + bool exportVolumeExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1289,6 +1850,95 @@ exportVolumeExtension(ExportGltfContext& ctx, return false; } +bool +exportVolumeExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + bool hasData = false; + hasData |= addTextureToExt( + ctx, inputTranslator, ext, m.volumeThickness, "thicknessTexture", "thicknessFactor"); + + VolumeMedium medium; + float attenuationDistance = 0.0f; + GfVec3f attenuationColor(1.0f); + if (resolveVolumeMedium(m, &medium) && + resolveAttenuationFromMedium(medium, &attenuationDistance, &attenuationColor)) { + addFloatValueToExt(ext, "attenuationDistance", attenuationDistance); + addColorValueToExt(ext, "attenuationColor", attenuationColor); + hasData = true; + } + + if (hasData) { + addMaterialExt(ctx, gm, "KHR_materials_volume", ext); + return true; + } + + return false; +} + +bool +exportVolumeScatterExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + bool hasData = false; + + // Texture export cannot depend on resolving a constant medium, since a textured + // transmission_scatter or subsurface_color input will intentionally not round-trip through the + // constant-value path. + if (isEnabledInput(m.transmission_weight) && m.transmission_scatter.image >= 0) { + float anisotropy = 0.0f; + getInputValue(m.transmission_scatter_anisotropy, &anisotropy); + Input multiscatterTexture; + if (inputTranslator.translateSingleScatterToMultiscatter("multiscatterColorTexture", + m.transmission_scatter, + anisotropy, + multiscatterTexture)) { + hasData |= addTextureToExt(ctx, + inputTranslator, + ext, + multiscatterTexture, + "multiscatterColorTexture", + "multiscatterColorFactor", + 0.0f); + } + } else if (isEnabledInput(m.subsurface_weight) && m.subsurface_color.image >= 0) { + // This fallback is only correct for the subsurface-only case. If both lobes are + // textured, a true coefficient-space texture blend would be needed here. + hasData |= addTextureToExt(ctx, + inputTranslator, + ext, + m.subsurface_color, + "multiscatterColorTexture", + "multiscatterColorFactor", + 0.0f); + } + + VolumeMedium medium; + if (resolveVolumeMedium(m, &medium)) { + if (medium.multiscatterAlbedo != GfVec3f(0.0f)) { + addColorValueToExt(ext, "multiscatterColorFactor", medium.multiscatterAlbedo); + hasData = true; + } + if (medium.anisotropy != 0.0f) { + addFloatValueToExt(ext, "scatterAnisotropy", medium.anisotropy); + hasData = true; + } + } + + if (hasData) { + addMaterialExt(ctx, gm, "KHR_materials_volume_scatter", ext); + return true; + } + + return false; +} + bool exportAdobeClearcoatSpecularExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1311,6 +1961,28 @@ exportAdobeClearcoatSpecularExtension(ExportGltfContext& ctx, return false; } +bool +exportAdobeClearcoatSpecularExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addTextureToExt(ctx, + inputTranslator, + ext, + m.coatSpecularLevel, + "clearcoatSpecularTexture", + "clearcoatSpecularFactor", + 1.0f) | + addFloatValueToExt(ext, "clearcoatIor", m.coat_ior.value, 1.5f)) { + addMaterialExt(ctx, gm, "ADOBE_materials_clearcoat_specular", ext); + return true; + } + + return false; +} + bool exportAdobeClearcoatColorExtension(ExportGltfContext& ctx, InputTranslator& inputTranslator, @@ -1331,6 +2003,22 @@ exportAdobeClearcoatColorExtension(ExportGltfContext& ctx, return false; } +bool +exportAdobeClearcoatColorExtension(ExportGltfContext& ctx, + InputTranslator& inputTranslator, + const OpenPbrMaterial& m, + tinygltf::Material& gm) +{ + ExtMap ext; + if (addTextureToExt( + ctx, inputTranslator, ext, m.coat_color, "clearcoatTintTexture", "clearcoatTintFactor")) { + addMaterialExt(ctx, gm, "ADOBE_materials_clearcoat_tint", ext); + return true; + } + + return false; +} + bool isSupportedGLTFImageFormat(const adobe::usd::ImageFormat format) { @@ -1345,60 +2033,149 @@ isSupportedGLTFImageFormat(const adobe::usd::ImageFormat format) } } -// Missing extensions relative to import: -// * KHR_materials_diffuse_transmission -// * KHR_materials_subsurface -// Both of these extensions are not yet ratified and we might not want to produce assets with these -// since the extensions could still change - void exportMaterials(ExportGltfContext& ctx) { InputTranslator inputTranslator(true, ctx.usd->images, DEBUG_TAG); - ctx.gltf->materials.resize(ctx.usd->materials.size()); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + size_t matCount = useOpenPbr ? ctx.usd->openPbrMaterials.size() : ctx.usd->materials.size(); + ctx.gltf->materials.resize(matCount); // map used to track created textures converted from anisotropy to avoid duplication - std::unordered_map constructedAnisotropyCache; - for (size_t i = 0; i < ctx.usd->materials.size(); i++) { - Material& m = ctx.usd->materials[i]; + std::unordered_map constructedAnisotropyTextureCache; + for (size_t i = 0; i < matCount; i++) { tinygltf::Material& gm = ctx.gltf->materials[i]; + const Material* material = useOpenPbr ? nullptr : &ctx.usd->materials[i]; + const OpenPbrMaterial* openPbrMaterial = + useOpenPbr ? &ctx.usd->openPbrMaterials[i] : nullptr; + if (useOpenPbr) { + gm.name = getNodeName(*openPbrMaterial); + } else { + gm.name = getNodeName(*material); + } + // In OpenPBR the diffuse albedo is base_color * base_weight, but glTF has no separate + // base weight, so multiply a constant base_weight into the base color to reach + // baseColorFactor. Skip when unauthored or a constant 1.0 so default materials export + // unchanged. A textured base_weight is skipped for now: translateProduct would + // sRGB-decode the weight along with base_color, but base_weight is a raw scalar. + Input weightedBaseColor; + bool useWeightedBaseColor = false; + if (useOpenPbr && !openPbrMaterial->base_weight.isEmpty()) { + if (openPbrMaterial->base_weight.image >= 0) { + TF_WARN("glTF export: textured OpenPBR base_weight is not applied to baseColor; " + "the exported base color ignores the weight map"); + } else if (getFloatInputValue(openPbrMaterial->base_weight, 1.0f) != 1.0f) { + useWeightedBaseColor = + inputTranslator.translateProduct("baseColorWeighted", + openPbrMaterial->base_color, + openPbrMaterial->base_weight, + weightedBaseColor, + /*intermediate=*/true, + /*linearize=*/true); + } + } + const Input& diffuseColor = + useOpenPbr ? (useWeightedBaseColor ? weightedBaseColor : openPbrMaterial->base_color) + : material->diffuseColor; + const Input& emissiveColor = + useOpenPbr ? openPbrMaterial->emission_color : material->emissiveColor; + Input opacity = useOpenPbr ? openPbrMaterial->geometry_opacity : material->opacity; + const Input& normalInput = useOpenPbr ? openPbrMaterial->geometry_normal : material->normal; + const Input& occlusionInput = useOpenPbr ? openPbrMaterial->occlusion : material->occlusion; + + const Input& metallicInput = + useOpenPbr ? openPbrMaterial->base_metalness : material->metallic; + const Input& transmissionInput = + useOpenPbr ? openPbrMaterial->transmission_weight : material->transmission; + const bool isUnlit = useOpenPbr ? openPbrMaterial->isUnlit : material->isUnlit; + + bool thinWalled = false; + if (useOpenPbr) { + thinWalled = getBoolInputValue(openPbrMaterial->geometry_thin_walled, false); + } + // In glTF, baseColor tints transmission, but in OpenPBR, base_color is the surface albedo + // and does not directly tint transmission. To avoid the glTF renderer incorrectly tinting + // transmitted light with the surface color, we blend base_color toward the appropriate + // target weighted by transmission_weight: + // - Thin-walled or zero transmission_depth: blend toward transmission_color, which IS + // the intended tint (OpenPBR equivalent of glTF's baseColor-as-tint). + // - Volumetric: blend toward white, because the attenuation medium handles the tinting + // and base_color should not contribute. + // The result is used in place of diffuseColor when writing the glTF baseColor. + Input transmissionAdjustedBaseColor; + if (useOpenPbr && !isUnlit && isEnabledInput(transmissionInput)) { + const float transmissionDepth = + getFloatInputValue(openPbrMaterial->transmission_depth, 0.0f); + const bool isThinWalledOrZeroDepth = thinWalled || transmissionDepth == 0.0f; + + Input target; + if (isThinWalledOrZeroDepth && !openPbrMaterial->transmission_color.isEmpty()) { + // Use transmission_color as the tint target for thin-walled materials. + target = openPbrMaterial->transmission_color; + } else { + // Use white so base_color has no effect on the transmission tint. + target.value = GfVec3f(1.0f, 1.0f, 1.0f); + } - gm.name = getNodeName(m); - // If we're not exporting material extensions which can express transmission directly, we - // map it to opacity since transmission is an important effect we want to capture, even if - // approximated as opacity - if (!ctx.options.useMaterialExtensions && !m.transmission.isEmpty()) { - m.opacity = m.transmission; - GfVec4f scale = m.opacity.scale; - GfVec4f bias = m.opacity.bias; - - // When converting from transmission to opacity, we should not convert full transmission - // into zero opacity, since that completely removes the material. It also prevents any - // of the original surface color from coming through. So we limit the transmission to - // 75%, which will lead to a minimum opacity of 25%, which makes sure transparent - // objects do not completely disappear or lose their tint. + if (!inputTranslator.translateLerp("baseColorTransmissionAdjusted", + diffuseColor, + target, + transmissionInput, + transmissionAdjustedBaseColor, + true, + true)) { + transmissionAdjustedBaseColor = diffuseColor; + } + } + + const Input& exportBaseColor = + transmissionAdjustedBaseColor.isEmpty() ? diffuseColor : transmissionAdjustedBaseColor; + + const float normalScale = useOpenPbr + ? openPbrMaterial->normalScale + : (material->normalScale.value.IsHolding() + ? material->normalScale.value.UncheckedGet() + : 1.0f); + const float opacityThreshold = + useOpenPbr ? openPbrMaterial->opacityThreshold + : (material->opacityThreshold.value.IsHolding() + ? material->opacityThreshold.value.UncheckedGet() + : 0.0f); + // If we're not exporting material extensions which can express transmission directly, + // we map it to opacity since transmission is an important effect we want to capture, + // even if approximated as opacity + if (!ctx.options.useMaterialExtensions && !transmissionInput.isEmpty()) { + opacity = transmissionInput; + GfVec4f scale = opacity.scale; + GfVec4f bias = opacity.bias; + + // When converting from transmission to opacity, we should not convert full + // transmission into zero opacity, since that completely removes the material. It + // also prevents any of the original surface color from coming through. So we limit + // the transmission to 75%, which will lead to a minimum opacity of 25%, which makes + // sure transparent objects do not completely disappear or lose their tint. static const float maxTransmissionFactor = 0.75f; scale *= maxTransmissionFactor; // Transmission is inverted relative to opacity. So we invert using scale and bias, // considering that there could be a previous scale and bias. - m.opacity.scale = -scale; - m.opacity.bias = GfVec4f(1.0f) - bias; + opacity.scale = -scale; + opacity.bias = GfVec4f(1.0f) - bias; TF_DEBUG_MSG(FILE_FORMAT_GLTF, "glTF::write material %s, using transmission for opacity\n", gm.name.c_str()); } - if (m.opacity.image >= 0) { - // Unwarranted opacity is expensive and leads to rendering errors, so we check the pixel - // values, which is expensive + if (opacity.image >= 0) { + // Unwarranted opacity is expensive and leads to rendering errors, so we check the + // pixel values, which is expensive // XXX since we only need the range for a single channel it is probably cheaper to - // compute the range just for that. But we can't avoid reading the texture as a whole - // since channels are packed. + // compute the range just for that. But we can't avoid reading the texture as a + // whole since channels are packed. float texOpacity = -1.0f; - int ch = token2Channel(m.opacity.channel); + int ch = token2Channel(opacity.channel); if (ch >= 0) { - auto [minRgba, maxRgba] = inputTranslator.computeRange(m.opacity); + auto [minRgba, maxRgba] = inputTranslator.computeRange(opacity); float minValue = minRgba[ch]; float maxValue = maxRgba[ch]; @@ -1416,23 +2193,23 @@ exportMaterials(ExportGltfContext& ctx) } } - // We have a constant value and don't need a texture (or we need to ignore it because - // the channel is invalid) + // We have a constant value and don't need a texture (or we need to ignore it + // because the channel is invalid) if (texOpacity >= 0 || ch < 0) { float opacityValue = 1.0f; if (ch >= 0) { - opacityValue = m.opacity.scale[ch] * texOpacity + m.opacity.bias[ch]; + opacityValue = opacity.scale[ch] * texOpacity + opacity.bias[ch]; } else { // the channel token is invalid (eg rgb) so we default to an opacity value // of 1.0 TF_WARN("An invalid channel identifier was provided resulting in the opacity " "texture being ignored. A default opacity of 1.0 is used."); } - m.opacity.image = -1; - m.opacity.value = opacityValue; + opacity.image = -1; + opacity.value = opacityValue; // Clear the scale and bias since it was applied to the constant value - m.opacity.scale = kDefaultTexScale; - m.opacity.bias = kDefaultTexBias; + opacity.scale = kDefaultTexScale; + opacity.bias = kDefaultTexBias; TF_DEBUG_MSG(FILE_FORMAT_GLTF, "glTF::write opacity for %s is a constant %f (texture omitted)\n", gm.name.c_str(), @@ -1440,12 +2217,11 @@ exportMaterials(ExportGltfContext& ctx) } } float constOpacity = -1.0f; - if (m.opacity.image >= 0 || - (getInputValue(m.opacity, &constOpacity) && constOpacity != 1.0f)) { + if (opacity.image >= 0 || (getInputValue(opacity, &constOpacity) && constOpacity != 1.0f)) { TF_DEBUG_MSG(FILE_FORMAT_GLTF, "glTF::write material %s, opacity in use (image %d, const %f)\n", gm.name.c_str(), - m.opacity.image, + opacity.image, constOpacity); gm.alphaMode = "BLEND"; } @@ -1457,24 +2233,24 @@ exportMaterials(ExportGltfContext& ctx) emptyInput.value = 0.0f; // If we have the unlit flag, that means the material comes originally comes from a glTF - // that used the unlit extension, and we imported the base color as emissive. In this case, - // we should use the emissive color as the base color instead to be consistent with the - // original file - Input& color = m.isUnlit ? m.emissiveColor : m.diffuseColor; + // that used the unlit extension, and we imported the base color as emissive. In this + // case, we should use the emissive color as the base color instead to be consistent + // with the original file + const Input& color = isUnlit ? emissiveColor : exportBaseColor; - if (m.opacity.image >= 0 || !m.opacity.value.IsEmpty()) { + if (opacity.image >= 0 || !opacity.value.IsEmpty()) { // Create a texture that combines diffuse color and opacity in the alpha channel TF_DEBUG_MSG(FILE_FORMAT_GLTF, "glTF::write material %s, generating baseColor and opacity texture\n", gm.name.c_str()); // GLTF can't express the bias on a texture, so if a texture uses bias we need to - // process the pixels and incorporate it into the texel data. Note, this always happens - // when we turn transmission into opacity in the code above. - if (m.opacity.bias != kDefaultTexBias) { - Input opacity = m.opacity; - int chIdx = m.opacity.image >= 0 ? token2Channel(m.opacity.channel) : 0; - float opacityScale = m.opacity.scale[chIdx]; - float opacityBias = m.opacity.bias[chIdx]; + // process the pixels and incorporate it into the texel data. Note, this always + // happens when we turn transmission into opacity in the code above. + if (opacity.bias != kDefaultTexBias) { + Input affineOpacity = opacity; + int chIdx = opacity.image >= 0 ? token2Channel(opacity.channel) : 0; + float opacityScale = opacity.scale[chIdx]; + float opacityBias = opacity.bias[chIdx]; TF_DEBUG_MSG(FILE_FORMAT_GLTF, "glTF::write material %s, opacity uses bias -> affine transform " "image: %d %f %f\n", @@ -1482,36 +2258,41 @@ exportMaterials(ExportGltfContext& ctx) chIdx, opacityScale, opacityBias); - inputTranslator.translateAffine( - "opacity", m.opacity, opacityScale, opacityBias, opacity, /*intermediate=*/true); + inputTranslator.translateAffine("opacity", + opacity, + opacityScale, + opacityBias, + affineOpacity, + /*intermediate=*/true); // Replace the old opacity - m.opacity = opacity; + opacity = affineOpacity; } - // translateMix reverts to a translateDirect call (albeit with transformation copying) - // if all of the input channels are from the same image in the same order, and the name - // will be based on the input image's name, as opposed to "baseColor" created here. - // This ensures that if the same texture has opacity only in some instances, this call - // and the translateDirect call below won't cause the texture to be duplicated. + // translateMix reverts to a translateDirect call (albeit with transformation + // copying) if all of the input channels are from the same image in the same order, + // and the name will be based on the input image's name, as opposed to "baseColor" + // created here. This ensures that if the same texture has opacity only in some + // instances, this call and the translateDirect call below won't cause the texture + // to be duplicated. inputTranslator.translateMix("baseColor", AdobeTokens->sRGB, inputTranslator.split3f(color, 0), inputTranslator.split3f(color, 1), inputTranslator.split3f(color, 2), - m.opacity, + opacity, baseColor); } else { // No opacity! Just use diffuseColor as baseColor inputTranslator.translateDirect(color, baseColor); } - if (m.isUnlit) { - // If the material is unlit (see above), the emissive stores the underlying color, not - // actually an emissive material + if (isUnlit) { + // If the material is unlit (see above), the emissive stores the underlying color, + // not actually an emissive material emissive.value = GfVec4f(0.0f); } else { - inputTranslator.translateDirect(m.emissiveColor, emissive); + inputTranslator.translateDirect(emissiveColor, emissive); } - inputTranslator.translateDirect(m.normal, normal); + inputTranslator.translateDirect(normalInput, normal); exportTexture(ctx, baseColor, @@ -1524,20 +2305,48 @@ exportMaterials(ExportGltfContext& ctx) exportTexture(ctx, normal, gm.normalTexture.index, gm.normalTexture.texCoord); // Get the normal scale from the normal scale input if it is holding a single value - if (m.normalScale.value.IsHolding()) { - gm.normalTexture.scale = m.normalScale.value.UncheckedGet(); + if (normalScale != 1.0f) { + gm.normalTexture.scale = normalScale; } exportTextureTransform(ctx, normal, gm.normalTexture.extensions); + // We need to export anisotropy before roughness and metallic since the conversion from + // OpenPBR specular roughness to glTF roughness due to anisotropy may generate a new + // Input image that needs to be exported as the roughness texture + Input newRoughnessInput; + if (useOpenPbr) { + newRoughnessInput = openPbrMaterial->specular_roughness; + } + if (ctx.options.useMaterialExtensions) { + if (useOpenPbr) { + newRoughnessInput = openPbrMaterial->specular_roughness; + exportAnisotropyExtensionOpenPBR(ctx, + inputTranslator, + *openPbrMaterial, + gm, + constructedAnisotropyTextureCache, + newRoughnessInput); + } else { + exportAnisotropyExtension( + ctx, inputTranslator, *material, gm, constructedAnisotropyTextureCache); + } + } + + const Input& roughnessInput = useOpenPbr ? newRoughnessInput : material->roughness; + // Occlusion texture needs to be in the r channel - bool needToPackOcclusion = m.occlusion.image >= 0 && m.occlusion.channel != AdobeTokens->r; + bool needToPackOcclusion = + occlusionInput.image >= 0 && occlusionInput.channel != AdobeTokens->r; // Roughness texture needs to be in the g channel - bool needToPackRoughness = m.roughness.image >= 0 && m.roughness.channel != AdobeTokens->g; + bool needToPackRoughness = + roughnessInput.image >= 0 && roughnessInput.channel != AdobeTokens->g; // Metallic texture needs to be in the b channel - bool needToPackMetallic = m.metallic.image >= 0 && m.metallic.channel != AdobeTokens->b; + bool needToPackMetallic = + metallicInput.image >= 0 && metallicInput.channel != AdobeTokens->b; // Roughness and metallic need to be in the same texture - bool needToPackRoughnessWithMetallic = - m.roughness.image >= 0 && m.metallic.image >= 0 && m.roughness.image != m.metallic.image; + bool needToPackRoughnessWithMetallic = roughnessInput.image >= 0 && + metallicInput.image >= 0 && + roughnessInput.image != metallicInput.image; if (needToPackOcclusion || needToPackRoughness || needToPackMetallic || needToPackRoughnessWithMetallic) { @@ -1549,20 +2358,20 @@ exportMaterials(ExportGltfContext& ctx) needToPackRoughness, needToPackMetallic, needToPackRoughnessWithMetallic); - // XXX This is currently generating a 4 channel texture, where a 3 channel texture would - // do + // XXX This is currently generating a 4 channel texture, where a 3 channel texture + // would do Input occlusionRoughnessMetallic; Input solidAlphaInput; solidAlphaInput.value = 1.0f; inputTranslator.translateMix("occlusionRoughnessMetallic", AdobeTokens->raw, - m.occlusion, - m.roughness, - m.metallic, + occlusionInput, + roughnessInput, + metallicInput, solidAlphaInput, occlusionRoughnessMetallic); - if (m.roughness.image >= 0 || m.metallic.image >= 0) { + if (roughnessInput.image >= 0 || metallicInput.image >= 0) { exportTexture(ctx, occlusionRoughnessMetallic, gm.pbrMetallicRoughness.metallicRoughnessTexture.index, @@ -1571,7 +2380,7 @@ exportMaterials(ExportGltfContext& ctx) occlusionRoughnessMetallic, gm.pbrMetallicRoughness.metallicRoughnessTexture.extensions); } - if (m.occlusion.image >= 0) { + if (occlusionInput.image >= 0) { exportTexture(ctx, occlusionRoughnessMetallic, gm.occlusionTexture.index, @@ -1583,21 +2392,21 @@ exportMaterials(ExportGltfContext& ctx) // Either roughness and metallic are already in the same texture, or we have at most // one of them - inputTranslator.translateDirect(m.occlusion, occlusion); + inputTranslator.translateDirect(occlusionInput, occlusion); // The roughness texture (if valid) also contains the metallic data, so one transfer - // is enough. If it's invalid, use the metallic texture instead. If both are invalid, - // exportTexture and exportTextureTransform will do nothing. + // is enough. If it's invalid, use the metallic texture instead. If both are + // invalid, exportTexture and exportTextureTransform will do nothing. Input roughnessMetallic; - inputTranslator.translateDirect(m.roughness.image >= 0 ? m.roughness : m.metallic, - roughnessMetallic); + inputTranslator.translateDirect( + roughnessInput.image >= 0 ? roughnessInput : metallicInput, roughnessMetallic); // Emit a warning if there are both roughness and metallic textures and their // transforms differ - if ((m.roughness.image >= 0 && m.metallic.image >= 0) && - (m.roughness.uvRotation != m.metallic.uvRotation || - m.roughness.uvScale != m.metallic.uvScale || - m.roughness.uvTranslation != m.metallic.uvTranslation)) { + if ((roughnessInput.image >= 0 && metallicInput.image >= 0) && + (roughnessInput.uvRotation != metallicInput.uvRotation || + roughnessInput.uvScale != metallicInput.uvScale || + roughnessInput.uvTranslation != metallicInput.uvTranslation)) { TF_WARN("glTF::write material %s, roughness and metallic textures have different " "transforms but will be combined into a single texture\n", @@ -1615,30 +2424,30 @@ exportMaterials(ExportGltfContext& ctx) ctx, roughnessMetallic, gm.pbrMetallicRoughness.metallicRoughnessTexture.extensions); } - if (m.diffuseColor.image >= 0 && m.diffuseColor.scale != kDefaultTexScale) { + if (diffuseColor.image >= 0 && diffuseColor.scale != kDefaultTexScale) { gm.pbrMetallicRoughness.baseColorFactor.resize(4, 1); - gm.pbrMetallicRoughness.baseColorFactor[0] = m.diffuseColor.scale[0]; - gm.pbrMetallicRoughness.baseColorFactor[1] = m.diffuseColor.scale[1]; - gm.pbrMetallicRoughness.baseColorFactor[2] = m.diffuseColor.scale[2]; - } else if (m.diffuseColor.value.IsHolding()) { + gm.pbrMetallicRoughness.baseColorFactor[0] = diffuseColor.scale[0]; + gm.pbrMetallicRoughness.baseColorFactor[1] = diffuseColor.scale[1]; + gm.pbrMetallicRoughness.baseColorFactor[2] = diffuseColor.scale[2]; + } else if (diffuseColor.value.IsHolding()) { GfVec4f value = baseColor.value.UncheckedGet(); gm.pbrMetallicRoughness.baseColorFactor.resize(4, 1); gm.pbrMetallicRoughness.baseColorFactor[0] = value[0]; gm.pbrMetallicRoughness.baseColorFactor[1] = value[1]; gm.pbrMetallicRoughness.baseColorFactor[2] = value[2]; } - if (m.opacity.image >= 0 && m.opacity.scale != kDefaultTexScale) { + if (opacity.image >= 0 && opacity.scale != kDefaultTexScale) { gm.pbrMetallicRoughness.baseColorFactor.resize(4, 1); - gm.pbrMetallicRoughness.baseColorFactor[3] = m.opacity.scale[3]; - } else if (m.opacity.value.IsHolding()) { - float value = m.opacity.value.UncheckedGet(); + gm.pbrMetallicRoughness.baseColorFactor[3] = opacity.scale[3]; + } else if (opacity.value.IsHolding()) { + float value = opacity.value.UncheckedGet(); gm.pbrMetallicRoughness.baseColorFactor.resize(4, 1); gm.pbrMetallicRoughness.baseColorFactor[3] = value; } float emissiveStrength = 1.0f; - if (m.emissiveColor.image >= 0) { - if (m.emissiveColor.scale != kDefaultTexScale) { - GfVec4f scale = m.emissiveColor.scale; + if (emissiveColor.image >= 0) { + if (emissiveColor.scale != kDefaultTexScale) { + GfVec4f scale = emissiveColor.scale; // The emissiveFactor can only go up to 1.0 per component. Anything beyond that // needs to be handled by the emissiveStrength extension. float maxFactor = std::max(scale[0], std::max(scale[1], scale[2])); @@ -1659,8 +2468,8 @@ exportMaterials(ExportGltfContext& ctx) gm.emissiveFactor[1] = 1.0; gm.emissiveFactor[2] = 1.0; } - } else if (m.emissiveColor.value.IsHolding()) { - GfVec3f value = m.emissiveColor.value.UncheckedGet(); + } else if (emissiveColor.value.IsHolding()) { + GfVec3f value = emissiveColor.value.UncheckedGet(); // The emissiveFactor can only go up to 1.0 per component. Anything beyond that // needs to be handled by the emissiveStrength extension. float maxFactor = std::max(value[0], std::max(value[1], value[2])); @@ -1675,18 +2484,18 @@ exportMaterials(ExportGltfContext& ctx) gm.emissiveFactor[1] = value[1]; gm.emissiveFactor[2] = value[2]; } - if (m.occlusion.image >= 0 && m.occlusion.scale != kDefaultTexScale) { - gm.occlusionTexture.strength = m.occlusion.scale[0]; - } else if (m.occlusion.value.IsHolding()) { - float value = m.occlusion.value.UncheckedGet(); + if (occlusionInput.image >= 0 && occlusionInput.scale != kDefaultTexScale) { + gm.occlusionTexture.strength = occlusionInput.scale[0]; + } else if (occlusionInput.value.IsHolding()) { + float value = occlusionInput.value.UncheckedGet(); gm.occlusionTexture.strength = value; } - if (m.metallic.image >= 0) { - if (m.metallic.scale != kDefaultTexScale) { - gm.pbrMetallicRoughness.metallicFactor = m.metallic.scale[0]; + if (metallicInput.image >= 0) { + if (metallicInput.scale != kDefaultTexScale) { + gm.pbrMetallicRoughness.metallicFactor = metallicInput.scale[0]; } - } else if (m.metallic.value.IsHolding()) { - float value = m.metallic.value.UncheckedGet(); + } else if (metallicInput.value.IsHolding()) { + float value = metallicInput.value.UncheckedGet(); gm.pbrMetallicRoughness.metallicFactor = value; } else { // UsdPreviewSurface uses a default of 0.0, but GLTF has a default of 1.0. So if we @@ -1694,12 +2503,12 @@ exportMaterials(ExportGltfContext& ctx) gm.pbrMetallicRoughness.metallicFactor = 0.0f; } - if (m.roughness.image >= 0) { - if (m.roughness.scale != kDefaultTexScale) { - gm.pbrMetallicRoughness.roughnessFactor = m.roughness.scale[0]; + if (roughnessInput.image >= 0) { + if (roughnessInput.scale != kDefaultTexScale) { + gm.pbrMetallicRoughness.roughnessFactor = roughnessInput.scale[0]; } - } else if (m.roughness.value.IsHolding()) { - float value = m.roughness.value.UncheckedGet(); + } else if (roughnessInput.value.IsHolding()) { + float value = roughnessInput.value.UncheckedGet(); gm.pbrMetallicRoughness.roughnessFactor = value; } else { // UsdPreviewSurface uses a default of 0.5, but GLTF has a default of 1.0. So if we @@ -1707,40 +2516,65 @@ exportMaterials(ExportGltfContext& ctx) gm.pbrMetallicRoughness.roughnessFactor = 0.5f; } - if (m.opacityThreshold.image >= 0) { + if (!useOpenPbr && material->opacityThreshold.image >= 0) { // TODO: can opacityThreshold really be sourced? gm.alphaMode = "MASK"; gm.alphaCutoff = 0.5f; - } else if (m.opacityThreshold.value.IsHolding()) { - float value = m.opacityThreshold.value.UncheckedGet(); + } else if (opacityThreshold > 0.0f) { gm.alphaMode = "MASK"; - gm.alphaCutoff = value; + gm.alphaCutoff = opacityThreshold; } if (ctx.options.useMaterialExtensions) { - exportAnisotropyExtension(ctx, inputTranslator, m, gm, constructedAnisotropyCache); exportEmissiveStrengthExtension(ctx, inputTranslator, emissiveStrength, gm); - exportIorExtension(ctx, inputTranslator, m, gm); - exportSheenExtension(ctx, inputTranslator, m, gm); - exportSpecularExtension(ctx, inputTranslator, m, gm); - exportTransmissionExtension(ctx, inputTranslator, m, gm); - exportVolumeExtension(ctx, inputTranslator, m, gm); - - if (m.isUnlit) { - exportUnlitExtension(ctx, inputTranslator, m, gm); + if (useOpenPbr) { + exportIorExtension(ctx, inputTranslator, *openPbrMaterial, gm); + exportDiffuseRoughnessExtension(ctx, inputTranslator, *openPbrMaterial, gm); + exportFuzzExtension(ctx, inputTranslator, *openPbrMaterial, gm); + exportIridescenceExtension(ctx, inputTranslator, *openPbrMaterial, gm); + exportSpecularExtension(ctx, inputTranslator, *openPbrMaterial, gm); + exportTransmissionExtension(ctx, inputTranslator, *openPbrMaterial, gm); + exportDiffuseTransmissionExtension( + ctx, inputTranslator, *openPbrMaterial, gm, thinWalled); + if (!thinWalled) { + exportVolumeExtension(ctx, inputTranslator, *openPbrMaterial, gm); + exportVolumeScatterExtension(ctx, inputTranslator, *openPbrMaterial, gm); + exportDispersionExtension(ctx, inputTranslator, *openPbrMaterial, gm); + } + } else { + exportIorExtension(ctx, inputTranslator, *material, gm); + exportSheenExtension(ctx, inputTranslator, *material, gm); + exportSpecularExtension(ctx, inputTranslator, *material, gm); + exportTransmissionExtension(ctx, inputTranslator, *material, gm); + if (!thinWalled) { + exportVolumeExtension(ctx, inputTranslator, *material, gm); + } + } + + if (isUnlit) { + if (useOpenPbr) { + exportUnlitExtension(ctx, inputTranslator, *openPbrMaterial, gm); + } else { + exportUnlitExtension(ctx, inputTranslator, *material, gm); + } } // If the material was imported from GLTF and the clearcoat lobe was used to model - // tinting of transmission (something ASM natively doesn't support), then we should not - // export the clearcoat to GLTF here, since the shading model there will do the tint - // by default and the clearcoat is redundant at best, if not wrong. - bool exportClearcoat = !m.clearcoatModelsTransmissionTint; + // tinting of transmission (something ASM natively doesn't support), then we should + // not export the clearcoat to GLTF here, since the shading model there will do the + // tint by default and the clearcoat is redundant at best, if not wrong. + bool exportClearcoat = useOpenPbr ? true : !material->clearcoatModelsTransmissionTint; if (exportClearcoat) { - if (exportClearcoatExtension(ctx, inputTranslator, m, gm)) { - exportAdobeClearcoatSpecularExtension(ctx, inputTranslator, m, gm); - exportAdobeClearcoatColorExtension(ctx, inputTranslator, m, gm); + if (useOpenPbr) { + exportClearcoatExtension(ctx, inputTranslator, *openPbrMaterial, gm); + } else { + exportClearcoatExtension(ctx, inputTranslator, *material, gm); + } + if (useOpenPbr) { + exportCoatExtension(ctx, inputTranslator, *openPbrMaterial, gm); + } else { + exportCoatExtension(ctx, inputTranslator, *material, gm); } - exportCoatExtension(ctx, inputTranslator, m, gm); } } @@ -1922,8 +2756,8 @@ exportMeshes(ExportGltfContext& ctx) std::vector gltfTangents; if (mesh.tangents.values.size() > 0) { - // If we have both tangents and bitangents, we need to reconstruct the proper tangent - // format with handedness in w + // If we have both tangents and bitangents, we need to reconstruct the proper + // tangent format with handedness in w if (mesh.bitangents.values.size() == mesh.tangents.values.size() && mesh.normals.values.size() == mesh.tangents.values.size()) { @@ -2127,8 +2961,8 @@ exportMeshes(ExportGltfContext& ctx) jointIndicesValues[dstOffset + j] = jointIndex; jointWeightsValues[dstOffset + j] = jointWeight; // if jointWeight > 0, we need to possible merge duplicate joint indices. In - // many cases, both jointIndex and jointWeight will be zero so we can avoid this - // inner loop to check for duplicates + // many cases, both jointIndex and jointWeight will be zero so we can avoid + // this inner loop to check for duplicates if (jointWeight > 0.0f) { for (int jj = 0; jj < j; jj++) { // this avoids joint index repetition @@ -2295,8 +3129,8 @@ exportGltf(const ExportGltfOptions& options, UsdData& usd, tinygltf::Model& gltf } } - // exportNode should be called before exportSkeleton, since exportSkeleton needs the gltf node - // index map that is created in exportNode + // exportNode should be called before exportSkeleton, since exportSkeleton needs the gltf + // node index map that is created in exportNode exportSkeletons(ctx, offsetNode); // Convert extension sets into vectors diff --git a/gltf/src/gltfExport.h b/gltf/src/gltfExport.h index e712feb3..074dbc0f 100644 --- a/gltf/src/gltfExport.h +++ b/gltf/src/gltfExport.h @@ -44,10 +44,23 @@ struct ExportGltfContext // Map used to detect mesh instancing std::unordered_map usdMeshIndexToGltfMeshIndexMap; + // Maps USD light indices to glTF light indices. Only non-environment lights are inserted; + // a missing key means the light was skipped (e.g. LightType::Environment). + std::unordered_map usdLightIndexToGltfLightIndexMap; + // Map to convert from USD node indices to glTF node indices. Created in exportNode() std::unordered_map usdNodesToGltfNodes; }; +// Used to store the gltf texture index and texCoord for an exported anisotropy texture. It is used +// as a cache value to avoid regenerating anisotropy textures for multiple materials that share the +// same anisotropy texture. +struct ExportTextureCacheItem +{ + int textureIndex = -1; + int texCoord = -1; +}; + /// \ingroup usdgltf /// \brief Add a Material to an extension. void @@ -82,4 +95,7 @@ exportTexture(ExportGltfContext& ctx, const Input& input, int& textureIndex, int bool exportGltf(const ExportGltfOptions& options, UsdData& data, tinygltf::Model& model); +bool +exportTextureTransform(ExportGltfContext& ctx, const Input& input, ExtMap& extensions); + } \ No newline at end of file diff --git a/gltf/src/gltfImport.cpp b/gltf/src/gltfImport.cpp index eeafe592..245e8eb1 100644 --- a/gltf/src/gltfImport.cpp +++ b/gltf/src/gltfImport.cpp @@ -11,13 +11,18 @@ governing permissions and limitations under the License. */ #include "gltfImport.h" #include "debugCodes.h" -#include "gltfAnisotropy.h" +#include "gltfAnisotropyASM.h" +#include "gltfAnisotropyOpenPBR.h" #include "gltfSpecGloss.h" #include "importGltfContext.h" #include #include +#include #include +#include #include +#include +#include #include #include @@ -276,6 +281,230 @@ isInputUsed(const Input& input) return input.image >= 0 || !input.value.IsEmpty(); } +void +appendTranslatedImagesToUsdData(ImportGltfContext& ctx, + InputTranslator& inputTranslator, + const std::vector& translatedInputs) +{ + std::vector& translatedImages = inputTranslator.getImages(); + if (translatedImages.empty()) { + return; + } + + const int imageIndexOffset = static_cast(ctx.usd->images.size()); + ctx.usd->images.insert(ctx.usd->images.end(), translatedImages.begin(), translatedImages.end()); + + for (Input* input : translatedInputs) { + if (input != nullptr && input->image >= 0) { + input->image += imageIndexOffset; + } + } +} + +void +copyBaseSurfaceToCoat(ImportGltfContext& ctx, + OpenPbrMaterial& material, + const Input& weight, + const Input& color, + bool diffuseLobe) +{ + const bool coatAlreadyInUse = isInputUsed(material.coat_weight); + const Input existingCoatWeight = material.coat_weight; + const Input existingCoatRoughness = material.coat_roughness; + const Input existingCoatIor = material.coat_ior; + const Input existingCoatRoughnessAnisotropy = material.coat_roughness_anisotropy; + const Input existingCoatNormal = material.geometry_coat_normal; + const Input existingCoatTangent = material.geometry_coat_tangent; + const Input incomingCoatRoughness = material.specular_roughness; + const Input incomingCoatIor = material.specular_ior; + const Input incomingCoatRoughnessAnisotropy = material.specular_roughness_anisotropy; + const Input incomingCoatNormal = material.geometry_normal; + const Input incomingCoatTangent = material.geometry_tangent; + + if (coatAlreadyInUse) { + // Blend coat properties using: + // new_coat_color = lerp(white, existing_coat_color, existing_coat_weight) + // * lerp(white, color, weight) + // + // lerpA and lerpB are marked intermediate=true so they are stored as decoded pixels + // inside the translator (mImagesSrc) and never appended to ctx.usd->images. + // Only coat_weight and coat_color need to persist beyond the translator. + std::vector translatorImages = ctx.usd->images; + InputTranslator inputTranslator(true, translatorImages, DEBUG_TAG); + std::vector translatedInputs; + + material.coat_weight = Input{ VtValue(1.0f) }; + material.coat_darkening = Input{ VtValue(0.0f) }; + + Input white; + white.value = GfVec3f(1.0f, 1.0f, 1.0f); + + Input coatColorFromExisting; // lerp(white, existing_coat_color, existing_coat_weight) — + // intermediate + if (!inputTranslator.translateLerp("coatColorFromExisting", + white, + material.coat_color, + existingCoatWeight, + coatColorFromExisting, + true, + true)) { + coatColorFromExisting = material.coat_color; + } + Input coatColorFromTransmission; // lerp(white, color, weight) — intermediate + if (!inputTranslator.translateLerp("coatColorFromTransmission", + white, + color, + weight, + coatColorFromTransmission, + true, + true)) { + coatColorFromTransmission = color; + } + + if (inputTranslator.translateProduct("coatColorMerged", + coatColorFromExisting, + coatColorFromTransmission, + material.coat_color, + false, + true)) { + translatedInputs.push_back(&material.coat_color); + } else { + material.coat_color = isInputUsed(coatColorFromExisting) ? coatColorFromExisting + : coatColorFromTransmission; + } + + // Blends two coat inputs using existingCoatWeight as the lerp factor. When an input + // is not in use, its respective default is substituted. Empty defaults work for + // normals/tangents since translateLerp short-circuits to translateDirect when one side + // is empty. + auto mergeCoatInput = [&](const std::string& name, + const Input& existingInput, + const Input& incomingInput, + Input& outputInput, + const Input& defaultExisting, + const Input& defaultIncoming) { + const Input& effExisting = isInputUsed(existingInput) ? existingInput : defaultExisting; + const Input& effIncoming = isInputUsed(incomingInput) ? incomingInput : defaultIncoming; + + if (inputTranslator.translateLerp( + name, effIncoming, effExisting, existingCoatWeight, outputInput)) { + // TF_DEBUG_MSG(FILE_FORMAT_GLTF, " → lerped to image[%d]\n", outputInput.image); + translatedInputs.push_back(&outputInput); + } else { + TF_DEBUG_MSG(FILE_FORMAT_GLTF, + " → translateLerp failed, falling back to effective incoming\n"); + outputInput = effIncoming; + } + }; + + mergeCoatInput("coatRoughnessMerged", + existingCoatRoughness, + incomingCoatRoughness, + material.coat_roughness, + Input{ VtValue(0.0f) }, + Input{ VtValue(0.3f) }); + mergeCoatInput("coatRoughnessAnisotropyMerged", + existingCoatRoughnessAnisotropy, + incomingCoatRoughnessAnisotropy, + material.coat_roughness_anisotropy, + Input{ VtValue(0.0f) }, + Input{ VtValue(0.0f) }); + mergeCoatInput("coatIorMerged", + existingCoatIor, + incomingCoatIor, + material.coat_ior, + Input{ VtValue(1.6f) }, + Input{ VtValue(1.5f) }); + + // Normal maps are stored in texture-encoded form: (0.5, 0.5, 1.0) represents the + // flat tangent-space normal (0,0,1) before the scale/bias decode of (2,-1) is applied. + // Tangent maps use the OpenPBR convention: x=(cos+1)/2, y=(sin+1)/2, z=0.5, so + // (1.0, 0.5, 0.5) represents a zero-rotation tangent pointing along +X. + const Input defaultNormal{ VtValue(GfVec3f(0.5f, 0.5f, 1.0f)) }; + const Input defaultTangent{ VtValue(GfVec3f(1.0f, 0.5f, 0.5f)) }; + mergeCoatInput("coatNormalMerged", + existingCoatNormal, + incomingCoatNormal, + material.geometry_coat_normal, + defaultNormal, + defaultNormal); + mergeCoatInput("coatTangentMerged", + existingCoatTangent, + incomingCoatTangent, + material.geometry_coat_tangent, + defaultTangent, + defaultTangent); + + appendTranslatedImagesToUsdData(ctx, inputTranslator, translatedInputs); + } else { + material.coat_color = color; + material.coat_weight = weight; + material.coat_roughness = material.specular_roughness; + material.coat_roughness_anisotropy = material.specular_roughness_anisotropy; + material.geometry_coat_normal = material.geometry_normal; + material.geometry_coat_tangent = material.geometry_tangent; + // Use the same IOR for the coat as the specular layer to try to match + // the original reflection as closely as possible. + material.coat_ior = material.specular_ior; + material.coat_darkening.value = 0.0f; + } +} + +// For thin-walled materials, both the surface-tinting for transmission and diffuse transmission can +// be converted directly to the transmission and subsurface slabs in OpenPBR. In volume rendering +// (i.e. not thin-walled), OpenPBR only supports surface-level tinting in the transmission slab and +// only when transmission_depth is 0.0. Otherwise, we need to move the surface tinting to the coat +// layer to preserve it. +void +handleSurfaceGltfSurfaceTintingForOpenPBR(ImportGltfContext& ctx, + OpenPbrMaterial& material, + Input& diffuse_transmission_color, + bool hasTransmission, + bool hasSubsurface) +{ + const bool thinWalled = material.geometry_thin_walled.value.IsHolding() + ? material.geometry_thin_walled.value.UncheckedGet() + : true; + + if (hasSubsurface && isInputUsed(diffuse_transmission_color)) { + if (thinWalled) { + // OpenPBR does not have a dedicated diffuse transmission lobe in volume + // rendering. However, when thin_walled is true, the subsurface slab diffusely + // transmits light and the subsurface color acts as a tint on that transmission. + material.subsurface_color = diffuse_transmission_color; + material.subsurface_scatter_anisotropy.value = + 1.0f; // diffuse transmission forward-scatters the diffuse hemisphere + } else { + // The material is volumetric and we have surface tinting, so we need to move that + // tinting to the coat layer to preserve it. This also lets us use a fully rough base + // surface to model the diffuse transmission. + copyBaseSurfaceToCoat( + ctx, material, material.subsurface_weight, diffuse_transmission_color, true); + material.subsurface_scatter_anisotropy.value = 1.0f; + } + } + // If the material has transmission, we need to use the base color to tint the transmission. + if (hasTransmission) { + // If the material is thin-walled or has no attenuation depth, we can use the base color as + // the transmission color directly. + if (thinWalled || !isInputUsed(material.transmission_depth) || + (material.transmission_depth.value.IsHolding() && + material.transmission_depth.value.UncheckedGet() == 0.0f)) { + material.transmission_color = material.base_color; + importValue1(material.transmission_depth, 0.0f); + } else if (isInputUsed(material.base_color) && + (!material.base_color.value.IsHolding() || + (material.base_color.value.IsHolding() && + material.base_color.value.UncheckedGet() != + GfVec3f(1.0f, 1.0f, 1.0f)))) { + // Otherwise, we have volumetric attenuation so we need to use the coat layer to + // preserve the base color tinting of glTF. + copyBaseSurfaceToCoat( + ctx, material, material.transmission_weight, material.base_color, false); + } + } +} + bool importWebPTextureSource(const tinygltf::ExtensionMap& extensions, int* imageIndex) { @@ -410,7 +639,7 @@ importTexture(const tinygltf::Model* gltf, // (importImage already logged a warning) return false; } - tinygltf::Texture texture = gltf->textures[textureIndex]; + const tinygltf::Texture& texture = gltf->textures[textureIndex]; int samplerIndex = texture.sampler; if (samplerIndex >= 0 && static_cast(samplerIndex) < gltf->samplers.size()) { tinygltf::Sampler sampler = gltf->samplers[samplerIndex]; @@ -662,6 +891,10 @@ struct Coat double ior = 1.5; double colorFactor[3] = { 1.0, 1.0, 1.0 }; tinygltf::TextureInfo colorTexture; // rgb channels + double darkeningFactor = 1.0; + double anisotropyStrength = 0.0; + double anisotropyRotation = 0.0; + tinygltf::TextureInfo anisotropyTexture; // b channel }; bool @@ -688,6 +921,10 @@ importCoat(const tinygltf::ExtensionMap& extensions, Coat* coat, const std::stri } readDoubleArray(coatExt.Get("coatColorFactor"), coat->colorFactor, 3); readTextureInfo(coatExt.Get("coatColorTexture"), coat->colorTexture); + readDoubleValue(coatExt.Get("coatDarkeningFactor"), coat->darkeningFactor); + readDoubleValue(coatExt.Get("coatAnisotropyStrength"), coat->anisotropyStrength); + readDoubleValue(coatExt.Get("coatAnisotropyRotation"), coat->anisotropyRotation); + readTextureInfo(coatExt.Get("coatAnisotropyTexture"), coat->anisotropyTexture); return true; } @@ -727,6 +964,34 @@ importIor(const tinygltf::ExtensionMap& extensions, double* ior, const std::stri return false; } +struct Fuzz +{ + double factor = 0.0; + tinygltf::TextureInfo texture; // r channel + tinygltf::TextureInfo colorTexture; // rgb channels + double colorFactor[3] = { 1.0, 1.0, 1.0 }; + double roughnessFactor = 0.5; + tinygltf::TextureInfo roughnessTexture; // a channel +}; + +bool +importFuzz(const tinygltf::ExtensionMap& extensions, Fuzz* fuzz) +{ + auto extIt = extensions.find("KHR_materials_fuzz"); + if (extIt != extensions.end()) { + const tinygltf::Value& fuzzExt = extIt->second; + readDoubleValue(fuzzExt.Get("fuzzFactor"), fuzz->factor); + readTextureInfo(fuzzExt.Get("fuzzTexture"), fuzz->texture); + readTextureInfo(fuzzExt.Get("fuzzColorTexture"), fuzz->colorTexture); + readDoubleArray(fuzzExt.Get("fuzzColorFactor"), fuzz->colorFactor, 3); + readDoubleValue(fuzzExt.Get("fuzzRoughnessFactor"), fuzz->roughnessFactor); + readTextureInfo(fuzzExt.Get("fuzzRoughnessTexture"), fuzz->roughnessTexture); + return true; + } + + return false; +} + struct Sheen { double colorFactor[3] = { 0.0, 0.0, 0.0 }; @@ -775,6 +1040,56 @@ importSpecular(const tinygltf::ExtensionMap& extensions, Specular* specular) return false; } +struct Iridescence +{ + double factor = 0.0; + tinygltf::TextureInfo texture; // r channel + double ior = 1.3; + double thickness = 400.0; + tinygltf::TextureInfo thicknessTexture; // g channel +}; + +bool +importIridescence(const tinygltf::ExtensionMap& extensions, Iridescence* iridescence) +{ + auto extIt = extensions.find("KHR_materials_iridescence"); + if (extIt != extensions.end()) { + const tinygltf::Value& iridExt = extIt->second; + readDoubleValue(iridExt.Get("iridescenceFactor"), iridescence->factor); + readTextureInfo(iridExt.Get("iridescenceTexture"), iridescence->texture); + readDoubleValue(iridExt.Get("iridescenceIor"), iridescence->ior); + readDoubleValue(iridExt.Get("iridescenceThicknessMaximum"), iridescence->thickness); + iridescence->thickness *= 0.001; // convert from nanometers to micrometers + // TODO: we should handle the minimum thickness as well if we have a thickness texture. + // OpenPBR doesn't support this though so the texture would have to be processed to + // renormalize it. + readTextureInfo(iridExt.Get("iridescenceThicknessTexture"), iridescence->thicknessTexture); + return true; + } + + return false; +} + +struct DiffuseRoughness +{ + double factor = 1.0; + tinygltf::TextureInfo texture; // r channel +}; + +bool +importDiffuseRoughness(const tinygltf::ExtensionMap& extensions, DiffuseRoughness* diffuseRoughness) +{ + auto extIt = extensions.find("KHR_materials_diffuse_roughness"); + if (extIt != extensions.end()) { + const tinygltf::Value& drExt = extIt->second; + readDoubleValue(drExt.Get("diffuseRoughnessFactor"), diffuseRoughness->factor); + readTextureInfo(drExt.Get("diffuseRoughnessTexture"), diffuseRoughness->texture); + return true; + } + + return false; +} + struct Transmission { double factor = 0.0; @@ -809,6 +1124,10 @@ importVolume(const tinygltf::ExtensionMap& extensions, Volume* volume) if (auto extIt = extensions.find("KHR_materials_volume"); extIt != extensions.end()) { const tinygltf::Value& volumeExt = extIt->second; readDoubleValue(volumeExt.Get("thicknessFactor"), volume->thicknessFactor); + if (volume->thicknessFactor == 0.0) { + // If thickness factor is 0, we don't actually have a volume. + return false; + } readTextureInfo(volumeExt.Get("thicknessTexture"), volume->thicknessTexture); readDoubleValue(volumeExt.Get("attenuationDistance"), volume->attenuationDistance); readDoubleArray(volumeExt.Get("attenuationColor"), volume->attenuationColor, 3); @@ -875,6 +1194,24 @@ importAdobeClearcoatColor(const tinygltf::ExtensionMap& extensions, return false; } +struct Dispersion +{ + double dispersion = 0.0; +}; + +bool +importDispersion(const tinygltf::ExtensionMap& extensions, Dispersion* dispersion) +{ + auto extIt = extensions.find("KHR_materials_dispersion"); + if (extIt != extensions.end()) { + const tinygltf::Value& dispExt = extIt->second; + readDoubleValue(dispExt.Get("dispersion"), dispersion->dispersion); + return true; + } + + return false; +} + // This is not a ratified extension yet! // KHR_materials_diffuse_transmission struct DiffuseTransmission @@ -889,8 +1226,8 @@ bool importDiffuseTransmission(const tinygltf::ExtensionMap& extensions, DiffuseTransmission* diffuseTransmission) { - if (auto extIt = extensions.find("KHR_materials_diffuse_transmission"); - extIt != extensions.end()) { + auto extIt = extensions.find("KHR_materials_diffuse_transmission"); + if (extIt != extensions.end()) { const tinygltf::Value& dtExt = extIt->second; readDoubleValue(dtExt.Get("diffuseTransmissionFactor"), diffuseTransmission->factor); readTextureInfo(dtExt.Get("diffuseTransmissionTexture"), diffuseTransmission->texture); @@ -937,9 +1274,8 @@ importSubsurface(const tinygltf::ExtensionMap& extensions, Subsurface* subsurfac struct VolumeScatter { double scatterAnisotropy = 0.0; // ASM does not support scatter anisotropy but OpenPBR does - double multiscatterColor[3] = { 0.0, 0.0, 0.0 }; - double scatteringDistanceScale[3] = { 0.0, 0.0, 0.0 }; - double scatteringDistance = 1.0; + double multiscatterColorFactor[3] = { 0.0, 0.0, 0.0 }; + tinygltf::TextureInfo multiscatterColorTexture; // rgb channels }; bool @@ -948,78 +1284,16 @@ importVolumeScatter(const tinygltf::ExtensionMap& extensions, VolumeScatter* vol auto extIt = extensions.find("KHR_materials_volume_scatter"); if (extIt != extensions.end()) { const tinygltf::Value& sssExt = extIt->second; - readDoubleArray(sssExt.Get("multiscatterColor"), volumeScatter->multiscatterColor, 3); - - // Look up the previously-read volume extension to get the attenuation distance and color - double attenuationDistance = 0.0f; - GfVec3d attenuationColor(1.0, 1.0, 1.0); - auto volumeExtIt = extensions.find("KHR_materials_volume"); - if (extIt != extensions.end()) { - const tinygltf::Value& volumeExt = volumeExtIt->second; - readDoubleValue(volumeExt.Get("attenuationDistance"), attenuationDistance); - readDoubleArray(volumeExt.Get("attenuationColor"), attenuationColor.data(), 3); - } - - // Calculate the single-scattering albedo - // This formulation is taken directly from the ASM implementation in Eclair (in - // asm_volume_utils.h) - GfVec3f multiscatterColor(volumeScatter->multiscatterColor[0], - volumeScatter->multiscatterColor[1], - volumeScatter->multiscatterColor[2]); - GfVec3f s = GfVec3f(4.09712f) + GfCompMult(GfVec3f(4.20863f), multiscatterColor); - GfVec3f p = GfVec3f(9.59217f) + GfCompMult(GfVec3f(41.6808f), multiscatterColor) + - GfCompMult(GfVec3f(17.7126f), GfCompMult(multiscatterColor, multiscatterColor)); - s = s - GfVec3f(GfSqrt(p[0]), GfSqrt(p[1]), GfSqrt(p[2])); - GfVec3f singleScatteringAlbedo = GfVec3f(1.0f) - GfCompMult(s, s); - - // Calculate the extinction coefficient from the attenuation color already in the volume - // Now that we have the scattering extension, we know that this coefficient represents both - // absorption and scattering. We will convert it to ASM using only ASM's scattering - // properties. - GfVec3f extinctionCoefficient(-std::log(attenuationColor[0]) / attenuationDistance, - -std::log(attenuationColor[1]) / attenuationDistance, - -std::log(attenuationColor[2]) / attenuationDistance); - - // Calculate the extinction coefficient that would be considered to be from the scattering - // part of ASM. This code is partly taken from the ASM implementation in Eclair (in - // asm_volume_utils.h) It puts limits on the extinction coefficient to keep it in a - // reasonable range and determines an appropriate extinction coefficient using the single - // scattering albedo and scattering distance. - float scatterDistance = std::fmaxf(1e-3f, attenuationDistance); - const float minExtinction = 1.0f / scatterDistance; - GfVec3f extinctionFromScattering(minExtinction); - const float maxAlbedo = - std::fmaxf(singleScatteringAlbedo[0], - std::fmaxf(singleScatteringAlbedo[1], singleScatteringAlbedo[2])); - if (maxAlbedo > 0.0f) { - // The max extinction can only be this many times bigger than the min extinction. - constexpr float maxMultiplier = 1e3f; - constexpr float inverseMaxMultiplier = 1.0f / maxMultiplier; - GfVec3f multiplier = GfVec3f(maxAlbedo); - GfVec3f multiplier2 = GfVec3f(maxAlbedo * inverseMaxMultiplier); - multiplier2 = GfVec3f(std::fmaxf(singleScatteringAlbedo[0], multiplier2[0]), - std::fmaxf(singleScatteringAlbedo[1], multiplier2[1]), - std::fmaxf(singleScatteringAlbedo[2], multiplier2[2])); - multiplier = GfCompDiv(multiplier, multiplier2); - extinctionFromScattering = GfCompMult(extinctionFromScattering, multiplier); - } - // Once we have an extinction coeff from scattering, we can compare it to the real - // extinction coeff and determine the scatter_distance_scale that we need to apply to - // acheive the same amount of scattering and absorption. - GfVec3f scatterDistanceScale = GfCompDiv(extinctionFromScattering, extinctionCoefficient); - - // If the scatter distance scale ended up being greater than 1, we need to scale the scatter - // distance to compensate. - float maxScatterDistance = std::fmaxf( - scatterDistanceScale[0], std::fmaxf(scatterDistanceScale[1], scatterDistanceScale[2])); - if (maxScatterDistance > 1.0f) { - scatterDistance *= maxScatterDistance; - scatterDistanceScale = GfCompDiv(scatterDistanceScale, GfVec3f(maxScatterDistance)); - } - volumeScatter->scatteringDistance = scatterDistance; - volumeScatter->scatteringDistanceScale[0] = scatterDistanceScale[0]; - volumeScatter->scatteringDistanceScale[1] = scatterDistanceScale[1]; - volumeScatter->scatteringDistanceScale[2] = scatterDistanceScale[2]; + if (!readDoubleArray( + sssExt.Get("multiscatterColorFactor"), volumeScatter->multiscatterColorFactor, 3)) { + // Some older exporters use "multiscatterColor" + // instead of "multiscatterColorFactor" + readDoubleArray( + sssExt.Get("multiscatterColor"), volumeScatter->multiscatterColorFactor, 3); + } + readTextureInfo(sssExt.Get("multiscatterColorTexture"), + volumeScatter->multiscatterColorTexture); + readDoubleValue(sssExt.Get("scatterAnisotropy"), volumeScatter->scatterAnisotropy); return true; } @@ -1033,6 +1307,167 @@ importUnlit(const tinygltf::ExtensionMap& extensions) return extIt != extensions.end(); } +void +convertVolumeScatterToASM(ImportGltfContext& ctx, + const VolumeScatter& volumeScatter, + const Volume& volume, + Material& outMaterial) +{ + GfVec3f multiscatterColorFactor(volumeScatter.multiscatterColorFactor[0], + volumeScatter.multiscatterColorFactor[1], + volumeScatter.multiscatterColorFactor[2]); + importColorInput(ctx, + outMaterial.displayName, + "multiscatterColorTexture", + outMaterial.scatteringColor, + volumeScatter.multiscatterColorTexture, + volumeScatter.multiscatterColorFactor); + + GfVec3f singleScatteringAlbedo = multiscatterToSingleScatter(multiscatterColorFactor, 0.0f); + + GfVec3f attenuationColor = + GfVec3f(volume.attenuationColor[0], volume.attenuationColor[1], volume.attenuationColor[2]); + + // Calculate the extinction coefficient from the attenuation color already in the volume + // Now that we have the scattering extension, we know that this coefficient represents both + // absorption and scattering. We will convert it to ASM using only ASM's scattering + // properties. + GfVec3f extinctionCoefficient( + -std::max(std::log(attenuationColor[0]), 0.0f) / volume.attenuationDistance, + -std::max(std::log(attenuationColor[1]), 0.0f) / volume.attenuationDistance, + -std::max(std::log(attenuationColor[2]), 0.0f) / volume.attenuationDistance); + + // Calculate the extinction coefficient that would be considered to be from the scattering + // part of ASM. This code is partly taken from the ASM implementation in Eclair (in + // asm_volume_utils.h) It puts limits on the extinction coefficient to keep it in a + // reasonable range and determines an appropriate extinction coefficient using the single + // scattering albedo and scattering distance. + float scatterDistance = std::fmaxf(1e-3f, volume.attenuationDistance); + const float minExtinction = 1.0f / scatterDistance; + GfVec3f extinctionFromScattering(minExtinction); + const float maxAlbedo = std::fmaxf( + singleScatteringAlbedo[0], std::fmaxf(singleScatteringAlbedo[1], singleScatteringAlbedo[2])); + if (maxAlbedo > 0.0f) { + // The max extinction can only be this many times bigger than the min extinction. + constexpr float maxMultiplier = 1e3f; + constexpr float inverseMaxMultiplier = 1.0f / maxMultiplier; + GfVec3f multiplier = GfVec3f(maxAlbedo); + GfVec3f multiplier2 = GfVec3f(maxAlbedo * inverseMaxMultiplier); + multiplier2 = GfVec3f(std::fmaxf(singleScatteringAlbedo[0], multiplier2[0]), + std::fmaxf(singleScatteringAlbedo[1], multiplier2[1]), + std::fmaxf(singleScatteringAlbedo[2], multiplier2[2])); + multiplier = GfCompDiv(multiplier, multiplier2); + extinctionFromScattering = GfCompMult(extinctionFromScattering, multiplier); + } + // Once we have an extinction coeff from scattering, we can compare it to the real + // extinction coeff and determine the scatter_distance_scale that we need to apply to + // achieve the same amount of scattering and absorption. + GfVec3f scatterDistanceScale = GfCompDiv(extinctionFromScattering, extinctionCoefficient); + + // If the scatter distance scale ended up being greater than 1, we need to scale the scatter + // distance to compensate. + float maxScatterDistance = std::fmaxf( + scatterDistanceScale[0], std::fmaxf(scatterDistanceScale[1], scatterDistanceScale[2])); + if (maxScatterDistance > 1.0f) { + scatterDistance *= maxScatterDistance; + scatterDistanceScale = GfCompDiv(scatterDistanceScale, GfVec3f(maxScatterDistance)); + } + double scatterDistanceScaleArray[3] = { scatterDistanceScale[0], + scatterDistanceScale[1], + scatterDistanceScale[2] }; + importValue3(outMaterial.scatteringDistanceScale, scatterDistanceScaleArray); + importValue1(outMaterial.scatteringDistance, scatterDistance); + // If we've imported the volume scatter extension, the attenuation color + // has been reinterpreted to include scattering and we need to erase the + // previously calculated absorption color. + double absorptionColor[3] = { 1.0, 1.0, 1.0 }; + importValue3(outMaterial.absorptionColor, absorptionColor); + importValue1(outMaterial.absorptionDistance, 0.0); +} + +void +convertAttenuationToOpenPBRSubsurface(const Volume& volume, + float& subsurface_radius, + GfVec3f& subsurface_radius_scale) +{ + GfVec3f attenuationColor = + GfVec3f(volume.attenuationColor[0], volume.attenuationColor[1], volume.attenuationColor[2]); + + // Calculate the extinction coefficient from the attenuation color already in the volume + GfVec3f extinctionCoefficient( + std::fmaxf(-std::log(attenuationColor[0]) / std::max(volume.attenuationDistance, 1e-6), + 1e-6f), + std::fmaxf(-std::log(attenuationColor[1]) / std::max(volume.attenuationDistance, 1e-6), + 1e-6f), + std::fmaxf(-std::log(attenuationColor[2]) / std::max(volume.attenuationDistance, 1e-6), + 1e-6f)); + + GfVec3f mfp = GfCompDiv(GfVec3f(1.0f), extinctionCoefficient); + subsurface_radius = std::fmaxf(std::fmaxf(mfp[0], std::fmaxf(mfp[1], mfp[2])), 1e-6f); + subsurface_radius_scale = GfCompDiv(mfp, GfVec3f(subsurface_radius)); +} + +// Convert the volume scatter properties to be used as subsurface slab in OpenPBR. +void +convertVolumeScatterToOpenPBRSubsurface(ImportGltfContext& ctx, + const VolumeScatter& volumeScatter, + const Volume& volume, + OpenPbrMaterial& outMaterial) +{ + importColorInput(ctx, + outMaterial.displayName, + "multiscatterColorTexture", + outMaterial.subsurface_color, + volumeScatter.multiscatterColorTexture, + volumeScatter.multiscatterColorFactor); + + float subsurface_radius = 0.0f; + GfVec3f subsurface_radius_scale(1.0f); + convertAttenuationToOpenPBRSubsurface(volume, subsurface_radius, subsurface_radius_scale); + double subsurface_radius_scale_array[3] = { subsurface_radius_scale[0], + subsurface_radius_scale[1], + subsurface_radius_scale[2] }; + importValue3(outMaterial.subsurface_radius_scale, subsurface_radius_scale_array); + importValue1(outMaterial.subsurface_radius, subsurface_radius); + importValue1(outMaterial.subsurface_scatter_anisotropy, volumeScatter.scatterAnisotropy); +} + +// Convert the volume scatter properties to be used as transmission scatter in OpenPBR. Note that +// this follows the spec of OpenPBR 1.2, not 1.1. In 1.2, transmission_scatter is defined directly +// as the single scatter albedo. +void +convertVolumeScatterToOpenPBRTransmission(ImportGltfContext& ctx, + const VolumeScatter& volumeScatter, + const Volume& volume, + OpenPbrMaterial& outMaterial) +{ + Input multiscatterInput; + importColorInput(ctx, + outMaterial.displayName, + "multiscatter", + multiscatterInput, + volumeScatter.multiscatterColorTexture, + volumeScatter.multiscatterColorFactor, + 0.0f); + + const size_t numOriginalImages = ctx.usd->images.size(); + std::vector translatorImages = ctx.usd->images; + InputTranslator inputTranslator(true, translatorImages, DEBUG_TAG); + inputTranslator.translateMultiscatterToSingleScatter("transmissionScatter", + multiscatterInput, + volumeScatter.scatterAnisotropy, + outMaterial.transmission_scatter); + if (outMaterial.transmission_scatter.image >= 0) { + outMaterial.transmission_scatter.image = + static_cast(numOriginalImages) + outMaterial.transmission_scatter.image; + } + for (ImageAsset& img : inputTranslator.getImages()) { + ctx.usd->images.push_back(std::move(img)); + } + + importValue1(outMaterial.transmission_scatter_anisotropy, volumeScatter.scatterAnisotropy); +} + void importMaterials(ImportGltfContext& ctx) { @@ -1042,483 +1477,986 @@ importMaterials(ImportGltfContext& ctx) // map used to track created textures converted from anisotropy to avoid duplication std::unordered_map anisotropyTextureCache; - ctx.usd->materials.resize(ctx.gltf->materials.size()); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + + if (useOpenPbr) { + ctx.usd->openPbrMaterials.resize(ctx.gltf->materials.size()); + } else { + ctx.usd->materials.resize(ctx.gltf->materials.size()); + } + for (size_t i = 0; i < ctx.gltf->materials.size(); i++) { // gm = glTF material, m = USD material const tinygltf::Material& gm = ctx.gltf->materials[i]; - Material& m = ctx.usd->materials[i]; - m.displayName = gm.name.empty() ? "Material" + std::to_string(i) : gm.name; - - // KHR_materials_pbrSpecularGlossiness data, in extensions, requires some cherrypicking. - auto it = gm.extensions.find("KHR_materials_pbrSpecularGlossiness"); - if (it != gm.extensions.end()) { - const tinygltf::Value& specGlossVal = it->second; - const tinygltf::Value& diffuseFactorVal = specGlossVal.Get("diffuseFactor"); - const tinygltf::Value& specularFactorVal = specGlossVal.Get("specularFactor"); - const tinygltf::Value& glossinessFactorVal = specGlossVal.Get("glossinessFactor"); - const tinygltf::Value& diffuseTextureVal = specGlossVal.Get("diffuseTexture"); - const tinygltf::Value& specGlossTextureVal = - specGlossVal.Get("specularGlossinessTexture"); - double diffuseFactor[4] = { 1, 1, 1, 1 }; // default diffuseFactor values - if (diffuseFactorVal.IsArray()) { - readDoubleArray(diffuseFactorVal, diffuseFactor, 4); - } - double specularFactor[3] = { 1, 1, 1 }; // default specularFactor values - if (specularFactorVal.IsArray()) { - readDoubleArray(specularFactorVal, specularFactor, 3); - } + if (useOpenPbr) { + OpenPbrMaterial& m = ctx.usd->openPbrMaterials[i]; + m.displayName = gm.name.empty() ? "Material" + std::to_string(i) : gm.name; + + // KHR_materials_pbrSpecularGlossiness data, in extensions, requires some cherrypicking. + auto it = gm.extensions.find("KHR_materials_pbrSpecularGlossiness"); + if (it != gm.extensions.end()) { + const tinygltf::Value& specGlossVal = it->second; + const tinygltf::Value& diffuseFactorVal = specGlossVal.Get("diffuseFactor"); + const tinygltf::Value& specularFactorVal = specGlossVal.Get("specularFactor"); + const tinygltf::Value& glossinessFactorVal = specGlossVal.Get("glossinessFactor"); + const tinygltf::Value& diffuseTextureVal = specGlossVal.Get("diffuseTexture"); + const tinygltf::Value& specGlossTextureVal = + specGlossVal.Get("specularGlossinessTexture"); + double diffuseFactor[4] = { 1, 1, 1, 1 }; // default diffuseFactor values + if (diffuseFactorVal.IsArray()) { + readDoubleArray(diffuseFactorVal, diffuseFactor, 4); + } - float glosinessFactor = 1.0; // default glossinessFactor - if (glossinessFactorVal.IsNumber()) { - glosinessFactor = glossinessFactorVal.GetNumberAsDouble(); - } + double specularFactor[3] = { 1, 1, 1 }; // default specularFactor values + if (specularFactorVal.IsArray()) { + readDoubleArray(specularFactorVal, specularFactor, 3); + } - Input diffuseColor; - Input specularColor; - Input opacity; - diffuseColor.value = - GfVec4f(diffuseFactor[0], diffuseFactor[1], diffuseFactor[2], diffuseFactor[3]); - specularColor.value = - GfVec4f(specularFactor[0], specularFactor[1], specularFactor[2], glosinessFactor); - - tinygltf::TextureInfo diffuseTextureInfo; - if (!readTextureInfo(diffuseTextureVal, diffuseTextureInfo)) - diffuseTextureInfo.index = -1; - if (diffuseTextureInfo.index >= 0) { - int imageIndex = - importImage(ctx, diffuseTextureInfo.index, m.displayName, "diffuse"); - importTexture(ctx.gltf, - imageIndex, - diffuseTextureInfo.index, - diffuseTextureInfo.texCoord, - diffuseColor, - AdobeTokens->rgb, - AdobeTokens->sRGB); - importTextureTransform(gm.extensions, diffuseColor); + float glosinessFactor = 1.0; // default glossinessFactor + if (glossinessFactorVal.IsNumber()) { + glosinessFactor = glossinessFactorVal.GetNumberAsDouble(); + } - if (gm.alphaMode == "BLEND" || gm.alphaMode == "MASK") { - opacity = diffuseColor; + Input diffuseColor; + Input specularColor; + Input opacity; + diffuseColor.value = + GfVec4f(diffuseFactor[0], diffuseFactor[1], diffuseFactor[2], diffuseFactor[3]); + specularColor.value = + GfVec4f(specularFactor[0], specularFactor[1], specularFactor[2], glosinessFactor); + + tinygltf::TextureInfo diffuseTextureInfo; + if (!readTextureInfo(diffuseTextureVal, diffuseTextureInfo)) + diffuseTextureInfo.index = -1; + if (diffuseTextureInfo.index >= 0) { + int imageIndex = + importImage(ctx, diffuseTextureInfo.index, m.displayName, "diffuse"); importTexture(ctx.gltf, imageIndex, diffuseTextureInfo.index, diffuseTextureInfo.texCoord, - opacity, - AdobeTokens->a, - AdobeTokens->raw); - importScale1(opacity, diffuseFactor[3]); + diffuseColor, + AdobeTokens->rgb, + AdobeTokens->sRGB); + importTextureTransform(gm.extensions, diffuseColor); + + if (gm.alphaMode == "BLEND" || gm.alphaMode == "MASK") { + opacity = diffuseColor; + importTexture(ctx.gltf, + imageIndex, + diffuseTextureInfo.index, + diffuseTextureInfo.texCoord, + opacity, + AdobeTokens->a, + AdobeTokens->raw); + importScale1(opacity, diffuseFactor[3]); + } } - } - tinygltf::TextureInfo specularTextureInfo; - if (!readTextureInfo(specGlossTextureVal, specularTextureInfo)) - specularTextureInfo.index = -1; - if (specularTextureInfo.index >= 0) { - int imageIndex = - importImage(ctx, specularTextureInfo.index, m.displayName, "specGloss"); - importTexture(ctx.gltf, - imageIndex, - specularTextureInfo.index, - specularTextureInfo.texCoord, - specularColor, - AdobeTokens->rgb, - AdobeTokens->sRGB); - importTextureTransform(gm.extensions, specularColor); - } + tinygltf::TextureInfo specularTextureInfo; + if (!readTextureInfo(specGlossTextureVal, specularTextureInfo)) + specularTextureInfo.index = -1; + if (specularTextureInfo.index >= 0) { + int imageIndex = + importImage(ctx, specularTextureInfo.index, m.displayName, "specGloss"); + importTexture(ctx.gltf, + imageIndex, + specularTextureInfo.index, + specularTextureInfo.texCoord, + specularColor, + AdobeTokens->rgb, + AdobeTokens->sRGB); + importTextureTransform(gm.extensions, specularColor); + } - translateSpecularGlossinessToMetallicRoughness(ctx, - specGlossTextureCache, - diffuseColor, - specularColor, - opacity, - gm.alphaMode, - m.diffuseColor, - m.opacity, - m.metallic, - m.roughness); + translateSpecularGlossinessToMetallicRoughness(ctx, + specGlossTextureCache, + diffuseColor, + specularColor, + opacity, + gm.alphaMode, + m.base_color, + m.geometry_opacity, + m.base_metalness, + m.specular_roughness); - } else { - int diffuseTexture = gm.pbrMetallicRoughness.baseColorTexture.index; - int mrTexture = gm.pbrMetallicRoughness.metallicRoughnessTexture.index; - const std::vector& diffuse = gm.pbrMetallicRoughness.baseColorFactor; - // Import pbrMetallicRoughness.baseColorTexture from glTF - if (diffuseTexture >= 0) { - int imageIndex = importImage(ctx, diffuseTexture, m.displayName, "diffuse"); - importTexture(ctx.gltf, - imageIndex, - diffuseTexture, - gm.pbrMetallicRoughness.baseColorTexture.texCoord, - m.diffuseColor, - AdobeTokens->rgb, - AdobeTokens->sRGB); - importScale3(m.diffuseColor, diffuse.data()); - importTextureTransform(gm.pbrMetallicRoughness.baseColorTexture.extensions, - m.diffuseColor); - if (gm.alphaMode == "BLEND" || gm.alphaMode == "MASK") { + } else { + // Import pbrMetallicRoughness.baseColorTexture from glTF + int diffuseTexture = gm.pbrMetallicRoughness.baseColorTexture.index; + int mrTexture = gm.pbrMetallicRoughness.metallicRoughnessTexture.index; + const std::vector& diffuse = gm.pbrMetallicRoughness.baseColorFactor; + if (diffuseTexture >= 0) { + int imageIndex = importImage(ctx, diffuseTexture, m.displayName, "diffuse"); importTexture(ctx.gltf, imageIndex, diffuseTexture, gm.pbrMetallicRoughness.baseColorTexture.texCoord, - m.opacity, - AdobeTokens->a, + m.base_color, + AdobeTokens->rgb, + AdobeTokens->sRGB); + importScale3(m.base_color, diffuse.data()); + importTextureTransform(gm.pbrMetallicRoughness.baseColorTexture.extensions, + m.base_color); + if (gm.alphaMode == "BLEND" || gm.alphaMode == "MASK") { + importTexture(ctx.gltf, + imageIndex, + diffuseTexture, + gm.pbrMetallicRoughness.baseColorTexture.texCoord, + m.geometry_opacity, + AdobeTokens->a, + AdobeTokens->raw); + importScale1(m.geometry_opacity, diffuse[3]); + m.geometry_opacity.uvRotation = m.base_color.uvRotation; + m.geometry_opacity.uvScale = m.base_color.uvScale; + m.geometry_opacity.uvTranslation = m.base_color.uvTranslation; + } + } else if (diffuse.size()) { + importValue3(m.base_color, diffuse.data()); + importValue1(m.geometry_opacity, diffuse[3]); + } + // Import pbrMetallicRoughness.metallicRoughnessTexture from glTF + if (mrTexture >= 0) { + int imageIndex = + importImage(ctx, mrTexture, m.displayName, "metallicRoughness"); + importTexture(ctx.gltf, + imageIndex, + mrTexture, + gm.pbrMetallicRoughness.metallicRoughnessTexture.texCoord, + m.specular_roughness, + AdobeTokens->g, + AdobeTokens->raw); + importTexture(ctx.gltf, + imageIndex, + mrTexture, + gm.pbrMetallicRoughness.metallicRoughnessTexture.texCoord, + m.base_metalness, + AdobeTokens->b, AdobeTokens->raw); - importScale1(m.opacity, diffuse[3]); - m.opacity.uvRotation = m.diffuseColor.uvRotation; - m.opacity.uvScale = m.diffuseColor.uvScale; - m.opacity.uvTranslation = m.diffuseColor.uvTranslation; + + importScale1(m.base_metalness, gm.pbrMetallicRoughness.metallicFactor); + importScale1(m.specular_roughness, gm.pbrMetallicRoughness.roughnessFactor); + importTextureTransform( + gm.pbrMetallicRoughness.metallicRoughnessTexture.extensions, + m.specular_roughness); + m.base_metalness.uvRotation = m.specular_roughness.uvRotation; + m.base_metalness.uvScale = m.specular_roughness.uvScale; + m.base_metalness.uvTranslation = m.specular_roughness.uvTranslation; + } else { + importValue1(m.base_metalness, gm.pbrMetallicRoughness.metallicFactor); + importValue1(m.specular_roughness, gm.pbrMetallicRoughness.roughnessFactor); } - } else if (diffuse.size()) { - importValue3(m.diffuseColor, diffuse.data()); - importValue1(m.opacity, diffuse[3]); - } - // Import pbrMetallicRoughness.metallicRoughnessTexture from glTF - if (mrTexture >= 0) { - int imageIndex = importImage(ctx, mrTexture, m.displayName, "metallicRoughness"); - importTexture(ctx.gltf, - imageIndex, - mrTexture, - gm.pbrMetallicRoughness.metallicRoughnessTexture.texCoord, - m.roughness, - AdobeTokens->g, - AdobeTokens->raw); - importTexture(ctx.gltf, - imageIndex, - mrTexture, - gm.pbrMetallicRoughness.metallicRoughnessTexture.texCoord, - m.metallic, - AdobeTokens->b, - AdobeTokens->raw); - importScale1(m.metallic, gm.pbrMetallicRoughness.metallicFactor); - importScale1(m.roughness, gm.pbrMetallicRoughness.roughnessFactor); - importTextureTransform(gm.pbrMetallicRoughness.metallicRoughnessTexture.extensions, - m.roughness); - m.metallic.uvRotation = m.roughness.uvRotation; - m.metallic.uvScale = m.roughness.uvScale; - m.metallic.uvTranslation = m.roughness.uvTranslation; - } else { - importValue1(m.metallic, gm.pbrMetallicRoughness.metallicFactor); - importValue1(m.roughness, gm.pbrMetallicRoughness.roughnessFactor); - } + double ior = 1.5; + importIor(gm.extensions, &ior, m.displayName); + importValue1(m.specular_ior, ior); - double ior = 1.5; - if (importIor(gm.extensions, &ior, m.displayName)) { - importValue1(m.ior, ior); - } + DiffuseRoughness diffuseRoughness; + if (importDiffuseRoughness(gm.extensions, &diffuseRoughness)) { + importInput(ctx, + m.displayName, + "diffuseRoughness", + m.base_diffuse_roughness, + diffuseRoughness.texture, + AdobeTokens->r, + &diffuseRoughness.factor); + } - Specular specular; - if (importSpecular(gm.extensions, &specular)) { - importInput(ctx, - m.displayName, - "specularLevel", - m.specularLevel, - specular.texture, - AdobeTokens->a, - &specular.factor, - 1.0); - importColorInput(ctx, - m.displayName, - "specularColor", - m.specularColor, - specular.colorTexture, - specular.colorFactor, - 1.0); - } + Specular specular; + if (importSpecular(gm.extensions, &specular)) { + importInput(ctx, + m.displayName, + "specularWeight", + m.specular_weight, + specular.texture, + AdobeTokens->a, + &specular.factor, + 1.0); + importColorInput(ctx, + m.displayName, + "specularColor", + m.specular_color, + specular.colorTexture, + specular.colorFactor, + 1.0); + } - auto extIt = gm.extensions.find("KHR_materials_anisotropy"); - if (extIt != gm.extensions.end()) { - AnisotropyData anisotropyData; - Image anisotropySrcImage; - float roughness = 0.0f; - if (m.roughness.value.IsHolding()) { - roughness = m.roughness.value.UncheckedGet(); + Iridescence iridescence; + if (importIridescence(gm.extensions, &iridescence)) { + importInput(ctx, + m.displayName, + "iridescence", + m.thin_film_weight, + iridescence.texture, + AdobeTokens->r, + &iridescence.factor); + importValue1(m.thin_film_ior, iridescence.ior); + importInput(ctx, + m.displayName, + "iridescenceThickness", + m.thin_film_thickness, + iridescence.thicknessTexture, + AdobeTokens->g, + &iridescence.thickness); } - if (importAnisotropyData(ctx, - gm.extensions, - extIt->second, - m, - roughness, - anisotropyData, - anisotropySrcImage)) { - importAnisotropyTexture(ctx, - gm, - m, - roughness, - anisotropyData, - anisotropySrcImage, - anisotropyTextureCache); + + auto extIt = gm.extensions.find("KHR_materials_anisotropy"); + if (extIt != gm.extensions.end()) { + importAnisotropyDataOpenPBR(ctx, gm, extIt->second, m, anisotropyTextureCache); } - } - Clearcoat clearcoat; - Coat coat; - if (importCoat(gm.extensions, &coat, m.displayName)) { - importInput(ctx, - m.displayName, - "coat", - m.clearcoat, - coat.texture, - AdobeTokens->r, - &coat.factor); - importInput(ctx, - m.displayName, - "coatRoughness", - m.clearcoatRoughness, - coat.roughnessTexture, - AdobeTokens->g, - &coat.roughnessFactor); - importNormalInput( - ctx, m.displayName, "coatNormal", m.clearcoatNormal, coat.normalTexture); - importValue1(m.clearcoatIor, coat.ior); - importColorInput(ctx, - m.displayName, - "coatColor", - m.clearcoatColor, - coat.colorTexture, - coat.colorFactor, - 1.0); - } else if (importClearcoat(gm.extensions, &clearcoat)) { - importInput(ctx, - m.displayName, - "clearcoat", - m.clearcoat, - clearcoat.texture, - AdobeTokens->r, - &clearcoat.factor); - importInput(ctx, - m.displayName, - "clearcoatRoughness", - m.clearcoatRoughness, - clearcoat.roughnessTexture, - AdobeTokens->g, - &clearcoat.roughnessFactor); - importNormalInput(ctx, - m.displayName, - "clearcoatNormal", - m.clearcoatNormal, - clearcoat.normalTexture); - - AdobeClearcoatSpecular clearcoatSpecular; - if (importAdobeClearcoatSpecular( - gm.extensions, &clearcoatSpecular, m.displayName)) { - importValue1(m.clearcoatIor, clearcoatSpecular.ior); + Clearcoat clearcoat; + Coat coat; + if (importCoat(gm.extensions, &coat, m.displayName)) { importInput(ctx, m.displayName, - "clearcoatSpecular", - m.clearcoatSpecular, - clearcoatSpecular.texture, - AdobeTokens->b, - &clearcoatSpecular.factor, - 1.0); - } - AdobeClearcoatColor clearcoatColor; - if (importAdobeClearcoatColor(gm.extensions, &clearcoatColor)) { + "coat", + m.coat_weight, + coat.texture, + AdobeTokens->r, + &coat.factor); + importInput(ctx, + m.displayName, + "coatRoughness", + m.coat_roughness, + coat.roughnessTexture, + AdobeTokens->g, + &coat.roughnessFactor); + importNormalInput( + ctx, m.displayName, "coatNormal", m.geometry_coat_normal, coat.normalTexture); + importValue1(m.coat_ior, coat.ior); importColorInput(ctx, m.displayName, - "clearcoatColor", - m.clearcoatColor, - clearcoatColor.texture, - clearcoatColor.factor, + "coatColor", + m.coat_color, + coat.colorTexture, + coat.colorFactor, 1.0); + importValue1(m.coat_darkening, coat.darkeningFactor); + importInput(ctx, + m.displayName, + "coatRoughnessAnisotropy", + m.coat_roughness_anisotropy, + coat.anisotropyTexture, + AdobeTokens->b, + &coat.anisotropyStrength); + } else if (importClearcoat(gm.extensions, &clearcoat)) { + importInput(ctx, + m.displayName, + "clearcoat", + m.coat_weight, + clearcoat.texture, + AdobeTokens->r, + &clearcoat.factor); + importInput(ctx, + m.displayName, + "clearcoatRoughness", + m.coat_roughness, + clearcoat.roughnessTexture, + AdobeTokens->g, + &clearcoat.roughnessFactor); + importNormalInput(ctx, + m.displayName, + "clearcoatNormal", + m.geometry_coat_normal, + clearcoat.normalTexture); + + AdobeClearcoatSpecular clearcoatSpecular; + if (importAdobeClearcoatSpecular( + gm.extensions, &clearcoatSpecular, m.displayName)) { + importValue1(m.coat_ior, clearcoatSpecular.ior); + importInput(ctx, + m.displayName, + "clearcoatSpecular", + m.coatSpecularLevel, + clearcoatSpecular.texture, + AdobeTokens->b, + &clearcoatSpecular.factor, + 1.0); + } + AdobeClearcoatColor clearcoatColor; + if (importAdobeClearcoatColor(gm.extensions, &clearcoatColor)) { + importColorInput(ctx, + m.displayName, + "clearcoatColor", + m.coat_color, + clearcoatColor.texture, + clearcoatColor.factor, + 1.0); + } } - } - Sheen sheen; - if (importSheen(gm.extensions, &sheen)) { - importColorInput(ctx, - m.displayName, - "sheenColor", - m.sheenColor, - sheen.colorTexture, - sheen.colorFactor); - importInput(ctx, - m.displayName, - "sheenRoughness", - m.sheenRoughness, - sheen.roughnessTexture, - AdobeTokens->a, - &sheen.roughnessFactor); - } + Fuzz fuzz; + Sheen sheen; + if (importFuzz(gm.extensions, &fuzz)) { + importInput(ctx, + m.displayName, + "fuzz", + m.fuzz_weight, + fuzz.texture, + AdobeTokens->r, + &fuzz.factor); + importColorInput(ctx, + m.displayName, + "fuzzColor", + m.fuzz_color, + fuzz.colorTexture, + fuzz.colorFactor); + importInput(ctx, + m.displayName, + "fuzzRoughness", + m.fuzz_roughness, + fuzz.roughnessTexture, + AdobeTokens->a, + &fuzz.roughnessFactor, + -1.0); // Use -1.0 as default so 0.0 values are imported - Transmission transmission; - bool hasTransmission = false; - if (importTransmission(gm.extensions, &transmission)) { - importInput(ctx, - m.displayName, - "transmission", - m.transmission, - transmission.texture, - AdobeTokens->r, - &transmission.factor); - hasTransmission = true; - // Note, the GLTF material model uses the baseColor to tint transmission through - // a surface. To emulate that behavior with ASM 4.0 we try to map the baseColor - // to the clearcoatColor and activate the clearcoat. This becomes complicated if - // the clearcoat is already in use. We try our best below, but we're not trying - // to blend signals to make this work at all cost - if (isInputUsed(m.diffuseColor)) { - if (!isInputUsed(m.clearcoat)) { - // Use the transmission strength as the strength for the lobe - m.clearcoat = m.transmission; - // Transfer the values from the regular specular lobe - m.clearcoatRoughness = m.roughness; - m.clearcoatNormal = m.normal; - m.clearcoatSpecular = m.specularLevel; - m.clearcoatIor = m.ior; - - if (!isInputUsed(m.clearcoatColor)) { - m.clearcoatColor = m.diffuseColor; - // Mark that material as having a specific purpose for the clearcoat - // that was not authored in the source asset - m.clearcoatModelsTransmissionTint = true; - } else { - TF_WARN("Can't map baseColor to clearcoatColor for transmission, since " - "clearcoatColor is in use, for material %s", - m.displayName.c_str()); - } - } else { - TF_DEBUG_MSG(FILE_FORMAT_GLTF, - "Can't touch clearcoat lobe to enable " - "transmission tinting on material %s\n", - m.displayName.c_str()); - } + } else if (importSheen(gm.extensions, &sheen)) { + importColorInput(ctx, + m.displayName, + "sheenColor", + m.fuzz_color, + sheen.colorTexture, + sheen.colorFactor); + importInput(ctx, + m.displayName, + "sheenRoughness", + m.fuzz_roughness, + sheen.roughnessTexture, + AdobeTokens->a, + &sheen.roughnessFactor); + m.fuzz_weight = Input{ VtValue(1.0f) }; } - } - DiffuseTransmission diffuseTransmission; - if (importDiffuseTransmission(gm.extensions, &diffuseTransmission)) { - // Note, the ASM 4.0 model does not have a diffuse transmission lobe, so we're - // approximating this effect by mapping it to general micro-facet transmission - // and volume absorption. Ideally we would make the micro-facet roughness very - // high to approach a diffuse transmission, but this would mess with general - // specular, so we're not changing roughness. - if (!hasTransmission) { + bool thinWalled = true; + Transmission transmission; + bool hasTransmission = false; + if (importTransmission(gm.extensions, &transmission)) { importInput(ctx, m.displayName, "transmission", - m.transmission, + m.transmission_weight, + transmission.texture, + AdobeTokens->r, + &transmission.factor); + hasTransmission = true; + } + + // Note that it's okay if both transmission and diffuse transmission extensions are + // present, since they are somewhat analogous to OpenPBR's transmission and + // subsurface lobes. Their weights even blend the same way. + DiffuseTransmission diffuseTransmission; + bool hasSubsurface = false; + // Temporary storage for diffuse transmission color imported from GLTF. This will + // either be applied as subsurface_color in the thin-walled case, or as a coat layer + // in a volume material. We don't know which case will be used until after all + // extensions are processed. + Input diffuse_transmission_color; + if (importDiffuseTransmission(gm.extensions, &diffuseTransmission)) { + hasSubsurface = true; + importInput(ctx, + m.displayName, + "diffuseTransmission", + m.subsurface_weight, diffuseTransmission.texture, AdobeTokens->a, &diffuseTransmission.factor); + + // This diffuse transmission color is temporary and will either be applied as + // subsurface_color in the thin-walled case, or as a coat layer in a volume + // material. This happens at the end of the import process, once all extensions + // have been processed. importColorInput(ctx, m.displayName, - "absorptionColor", - m.absorptionColor, + "diffuseTransmissionColor", + diffuse_transmission_color, diffuseTransmission.colorTexture, - diffuseTransmission.colorFactor); - } else { - TF_WARN("Material %s has both KHR_materials_transmission and " - "KHR_materials_diffuse_transmission. Ignoring the latter.", - m.displayName.c_str()); + diffuseTransmission.colorFactor, + 1.0f); } - } - Volume volume; - if (importVolume(gm.extensions, &volume) && volume.thicknessFactor > 0.0) { - importInput(ctx, - m.displayName, - "thickness", - m.volumeThickness, - volume.thicknessTexture, - AdobeTokens->g, - &volume.thicknessFactor); - importValue1(m.absorptionDistance, volume.attenuationDistance); - // absorptionColor from the extension is a constant and we use it as a - // multiplier on the existing absorptionColor, which is often the same as - // diffuse - GfVec3f mult(volume.attenuationColor[0], - volume.attenuationColor[1], - volume.attenuationColor[2]); - applyInputMultiplier(m.absorptionColor, mult); - } + Volume volume; + if (importVolume(gm.extensions, &volume)) { + if (hasTransmission) { + thinWalled = false; + importInput(ctx, + m.displayName, + "thickness", + m.volumeThickness, + volume.thicknessTexture, + AdobeTokens->g, + &volume.thicknessFactor); + importValue1(m.transmission_depth, volume.attenuationDistance); + importValue3(m.transmission_color, volume.attenuationColor); + } + if (hasSubsurface) { + thinWalled = false; + + float subsurface_radius = 0.0f; + GfVec3f subsurface_radius_scale(1.0f); + convertAttenuationToOpenPBRSubsurface( + volume, subsurface_radius, subsurface_radius_scale); + importValue1(m.subsurface_radius, subsurface_radius); + double subsurface_radius_scale_array[3] = { subsurface_radius_scale[0], + subsurface_radius_scale[1], + subsurface_radius_scale[2] }; + importValue3(m.subsurface_radius_scale, subsurface_radius_scale_array); + double no_scatter[3] = { 0.0, 0.0, 0.0 }; + importValue3(m.subsurface_color, no_scatter); + } + } + + if (!thinWalled) { + VolumeScatter volumeScatter; + if (importVolumeScatter(gm.extensions, &volumeScatter)) { + if (hasSubsurface) { + convertVolumeScatterToOpenPBRSubsurface(ctx, volumeScatter, volume, m); + } + if (hasTransmission) { + convertVolumeScatterToOpenPBRTransmission( + ctx, volumeScatter, volume, m); + } - VolumeScatter volumeScatter; - if (importVolumeScatter(gm.extensions, &volumeScatter)) { - importValue3(m.scatteringColor, volumeScatter.multiscatterColor); - importValue3(m.scatteringDistanceScale, volumeScatter.scatteringDistanceScale); - importValue1(m.scatteringDistance, volumeScatter.scatteringDistance); - // If we've imported the volume scatter extension, the attenuation color has been - // reinterpreted to include scattering and we need to erase the previously - // calculated absorption color. - double absorptionColor[3] = { 1.0, 1.0, 1.0 }; - importValue3(m.absorptionColor, absorptionColor); - importValue1(m.absorptionDistance, 0.0); + } else { + Subsurface subsurface; + if (importSubsurface(gm.extensions, &subsurface)) { + importValue1(m.subsurface_radius, subsurface.scatterDistance); + importValue3(m.subsurface_color, subsurface.scatterColor); + m.subsurface_weight = Input{ VtValue(1.0f) }; + } + } + } - } else { - Subsurface subsurface; - if (importSubsurface(gm.extensions, &subsurface)) { - importValue1(m.scatteringDistance, subsurface.scatterDistance); - importValue3(m.scatteringColor, subsurface.scatterColor); + Dispersion dispersion; + if (importDispersion(gm.extensions, &dispersion)) { + importValue1(m.transmission_dispersion_abbe_number, 20.0f); + importValue1(m.transmission_dispersion_scale, dispersion.dispersion); } + + m.geometry_thin_walled.value = VtValue(thinWalled); + handleSurfaceGltfSurfaceTintingForOpenPBR( + ctx, m, diffuse_transmission_color, hasTransmission, hasSubsurface); } - } - bool unlit = importUnlit(gm.extensions); - double emissiveStrength = 1.0; - importEmissionStrength(gm.extensions, &emissiveStrength); - if (gm.emissiveTexture.index >= 0) { - int imageIndex = importImage(ctx, gm.emissiveTexture.index, m.displayName, "emissive"); - importTexture(ctx.gltf, - imageIndex, - gm.emissiveTexture.index, - gm.emissiveTexture.texCoord, - m.emissiveColor, - AdobeTokens->rgb, - AdobeTokens->sRGB); - importScale3(m.emissiveColor, gm.emissiveFactor.data(), emissiveStrength); - importTextureTransform(gm.emissiveTexture.extensions, m.emissiveColor); - } else if (gm.emissiveFactor.size() == 3 && - (gm.emissiveFactor[0] > 0 || gm.emissiveFactor[1] > 0 || - gm.emissiveFactor[2] > 0)) { - importValue3(m.emissiveColor, gm.emissiveFactor.data(), emissiveStrength); - } else if (unlit) { - m.emissiveColor = m.diffuseColor; - std::array black = { 0, 0, 0 }; - importValue3(m.diffuseColor, black.data()); - m.isUnlit = true; - } - if (gm.alphaMode == "MASK") { - importValue1(m.opacityThreshold, gm.alphaCutoff); - } - - // Import normal map - if (gm.normalTexture.index >= 0) { - int imageIndex = importImage(ctx, gm.normalTexture.index, m.displayName, "normal"); + bool unlit = importUnlit(gm.extensions); + double emissiveStrength = 1.0; + importEmissionStrength(gm.extensions, &emissiveStrength); + if (gm.emissiveTexture.index >= 0) { + int imageIndex = + importImage(ctx, gm.emissiveTexture.index, m.displayName, "emissive"); + importTexture(ctx.gltf, + imageIndex, + gm.emissiveTexture.index, + gm.emissiveTexture.texCoord, + m.emission_color, + AdobeTokens->rgb, + AdobeTokens->sRGB); + importScale3(m.emission_color, gm.emissiveFactor.data(), emissiveStrength); + importTextureTransform(gm.emissiveTexture.extensions, m.emission_color); + m.emission_luminance = Input{ VtValue(1000.0f) }; + } else if (gm.emissiveFactor.size() == 3 && + (gm.emissiveFactor[0] > 0 || gm.emissiveFactor[1] > 0 || + gm.emissiveFactor[2] > 0)) { + importValue3(m.emission_color, gm.emissiveFactor.data(), emissiveStrength); + m.emission_luminance = Input{ VtValue(1000.0f) }; + } else if (unlit) { + m.emission_color = m.base_color; + std::array black = { 0, 0, 0 }; + importValue3(m.base_color, black.data()); + m.isUnlit = true; + } + if (gm.alphaMode == "MASK") { + m.opacityThreshold = static_cast(gm.alphaCutoff); + } + + // Import normal map + // Normal maps should not get the sRGB treatment and hence should be read as "raw" + // 8-bit channel data + if (gm.normalTexture.index >= 0) { + int imageIndex = importImage(ctx, gm.normalTexture.index, m.displayName, "normal"); + importTexture(ctx.gltf, + imageIndex, + gm.normalTexture.index, + gm.normalTexture.texCoord, + m.geometry_normal, + AdobeTokens->rgb, + AdobeTokens->raw); + importTextureTransform(gm.normalTexture.extensions, m.geometry_normal); + // normal.scale for 8-bit normal maps is 2,2,2,1 and normal.bias is -1,-1,-1, 0 + // We then incorporate the scale from the glTF normalTexture into the + // normal.scale and normal.bias. The official usdchecker will flag scale and bias + // that are not 2 and -1 for normal map texture readers: + // https://github.com/PixarAnimationStudios/USD/blob/release/pxr/usd/usdUtils/complianceChecker.py#L568 + float xyScale = 2.0f * gm.normalTexture.scale; + float xyBias = -1.0f * gm.normalTexture.scale; + m.geometry_normal.scale = GfVec4f(xyScale, xyScale, 2.0f, 1.0f); + m.geometry_normal.bias = GfVec4f(xyBias, xyBias, -1.0f, 0.0f); + m.normalScale = gm.normalTexture.scale; + } + if (gm.occlusionTexture.index >= 0) { + int imageIndex = + importImage(ctx, gm.occlusionTexture.index, m.displayName, "occlusion"); + importTexture(ctx.gltf, + imageIndex, + gm.occlusionTexture.index, + gm.occlusionTexture.texCoord, + m.occlusion, + AdobeTokens->r, + AdobeTokens->raw); + importScale1(m.occlusion, gm.occlusionTexture.strength); + importTextureTransform(gm.occlusionTexture.extensions, m.occlusion); + } else if (gm.occlusionTexture.strength != 1.0) { + importValue1(m.occlusion, gm.occlusionTexture.strength); + } + } else { + Material& m = ctx.usd->materials[i]; + m.displayName = gm.name.empty() ? "Material" + std::to_string(i) : gm.name; + + auto it = gm.extensions.find("KHR_materials_pbrSpecularGlossiness"); + if (it != gm.extensions.end()) { + const tinygltf::Value& specGlossVal = it->second; + const tinygltf::Value& diffuseFactorVal = specGlossVal.Get("diffuseFactor"); + const tinygltf::Value& specularFactorVal = specGlossVal.Get("specularFactor"); + const tinygltf::Value& glossinessFactorVal = specGlossVal.Get("glossinessFactor"); + const tinygltf::Value& diffuseTextureVal = specGlossVal.Get("diffuseTexture"); + const tinygltf::Value& specGlossTextureVal = + specGlossVal.Get("specularGlossinessTexture"); + double diffuseFactor[4] = { 1, 1, 1, 1 }; + if (diffuseFactorVal.IsArray()) { + readDoubleArray(diffuseFactorVal, diffuseFactor, 4); + } + double specularFactor[3] = { 1, 1, 1 }; + if (specularFactorVal.IsArray()) { + readDoubleArray(specularFactorVal, specularFactor, 3); + } + float glosinessFactor = 1.0; + if (glossinessFactorVal.IsNumber()) { + glosinessFactor = glossinessFactorVal.GetNumberAsDouble(); + } + Input diffuseColor; + Input specularColor; + Input opacity; + diffuseColor.value = + GfVec4f(diffuseFactor[0], diffuseFactor[1], diffuseFactor[2], diffuseFactor[3]); + specularColor.value = + GfVec4f(specularFactor[0], specularFactor[1], specularFactor[2], glosinessFactor); + tinygltf::TextureInfo diffuseTextureInfo; + if (!readTextureInfo(diffuseTextureVal, diffuseTextureInfo)) + diffuseTextureInfo.index = -1; + if (diffuseTextureInfo.index >= 0) { + int imageIndex = + importImage(ctx, diffuseTextureInfo.index, m.displayName, "diffuse"); + importTexture(ctx.gltf, + imageIndex, + diffuseTextureInfo.index, + diffuseTextureInfo.texCoord, + diffuseColor, + AdobeTokens->rgb, + AdobeTokens->sRGB); + importTextureTransform(gm.extensions, diffuseColor); + if (gm.alphaMode == "BLEND" || gm.alphaMode == "MASK") { + opacity = diffuseColor; + importTexture(ctx.gltf, + imageIndex, + diffuseTextureInfo.index, + diffuseTextureInfo.texCoord, + opacity, + AdobeTokens->a, + AdobeTokens->raw); + importScale1(opacity, diffuseFactor[3]); + } + } + tinygltf::TextureInfo specularTextureInfo; + if (!readTextureInfo(specGlossTextureVal, specularTextureInfo)) + specularTextureInfo.index = -1; + if (specularTextureInfo.index >= 0) { + int imageIndex = + importImage(ctx, specularTextureInfo.index, m.displayName, "specGloss"); + importTexture(ctx.gltf, + imageIndex, + specularTextureInfo.index, + specularTextureInfo.texCoord, + specularColor, + AdobeTokens->rgb, + AdobeTokens->sRGB); + importTextureTransform(gm.extensions, specularColor); + } + translateSpecularGlossinessToMetallicRoughness(ctx, + specGlossTextureCache, + diffuseColor, + specularColor, + opacity, + gm.alphaMode, + m.diffuseColor, + m.opacity, + m.metallic, + m.roughness); + } else { + // Import pbrMetallicRoughness.baseColorTexture from glTF + int diffuseTexture = gm.pbrMetallicRoughness.baseColorTexture.index; + int mrTexture = gm.pbrMetallicRoughness.metallicRoughnessTexture.index; + const std::vector& diffuse = gm.pbrMetallicRoughness.baseColorFactor; + if (diffuseTexture >= 0) { + int imageIndex = importImage(ctx, diffuseTexture, m.displayName, "diffuse"); + importTexture(ctx.gltf, + imageIndex, + diffuseTexture, + gm.pbrMetallicRoughness.baseColorTexture.texCoord, + m.diffuseColor, + AdobeTokens->rgb, + AdobeTokens->sRGB); + importScale3(m.diffuseColor, diffuse.data()); + importTextureTransform(gm.pbrMetallicRoughness.baseColorTexture.extensions, + m.diffuseColor); + if (gm.alphaMode == "BLEND" || gm.alphaMode == "MASK") { + importTexture(ctx.gltf, + imageIndex, + diffuseTexture, + gm.pbrMetallicRoughness.baseColorTexture.texCoord, + m.opacity, + AdobeTokens->a, + AdobeTokens->raw); + importScale1(m.opacity, diffuse[3]); + m.opacity.uvRotation = m.diffuseColor.uvRotation; + m.opacity.uvScale = m.diffuseColor.uvScale; + m.opacity.uvTranslation = m.diffuseColor.uvTranslation; + } + } else if (diffuse.size()) { + importValue3(m.diffuseColor, diffuse.data()); + importValue1(m.opacity, diffuse[3]); + } + // Import pbrMetallicRoughness.metallicRoughnessTexture from glTF + if (mrTexture >= 0) { + int imageIndex = + importImage(ctx, mrTexture, m.displayName, "metallicRoughness"); + importTexture(ctx.gltf, + imageIndex, + mrTexture, + gm.pbrMetallicRoughness.metallicRoughnessTexture.texCoord, + m.roughness, + AdobeTokens->g, + AdobeTokens->raw); + importTexture(ctx.gltf, + imageIndex, + mrTexture, + gm.pbrMetallicRoughness.metallicRoughnessTexture.texCoord, + m.metallic, + AdobeTokens->b, + AdobeTokens->raw); + importScale1(m.metallic, gm.pbrMetallicRoughness.metallicFactor); + importScale1(m.roughness, gm.pbrMetallicRoughness.roughnessFactor); + importTextureTransform( + gm.pbrMetallicRoughness.metallicRoughnessTexture.extensions, m.roughness); + m.metallic.uvRotation = m.roughness.uvRotation; + m.metallic.uvScale = m.roughness.uvScale; + m.metallic.uvTranslation = m.roughness.uvTranslation; + } else { + importValue1(m.metallic, gm.pbrMetallicRoughness.metallicFactor); + importValue1(m.roughness, gm.pbrMetallicRoughness.roughnessFactor); + } + double ior = 1.5; + if (importIor(gm.extensions, &ior, m.displayName)) { + importValue1(m.ior, ior); + } + Specular specular; + if (importSpecular(gm.extensions, &specular)) { + importInput(ctx, + m.displayName, + "specularLevel", + m.specularLevel, + specular.texture, + AdobeTokens->a, + &specular.factor, + 1.0); + importColorInput(ctx, + m.displayName, + "specularColor", + m.specularColor, + specular.colorTexture, + specular.colorFactor, + 1.0); + } + auto extIt = gm.extensions.find("KHR_materials_anisotropy"); + if (extIt != gm.extensions.end()) { + AnisotropyData anisotropyData; + Image anisotropySrcImage; + float roughness = 0.0f; + if (m.roughness.value.IsHolding()) { + roughness = m.roughness.value.UncheckedGet(); + } + if (importAnisotropyData(ctx, + gm.extensions, + extIt->second, + m, + roughness, + anisotropyData, + anisotropySrcImage)) { + importAnisotropyTexture(ctx, + gm, + m, + roughness, + anisotropyData, + anisotropySrcImage, + anisotropyTextureCache); + } + } + Clearcoat clearcoat; + Coat coat; + if (importCoat(gm.extensions, &coat, m.displayName)) { + importInput(ctx, + m.displayName, + "coat", + m.clearcoat, + coat.texture, + AdobeTokens->r, + &coat.factor); + importInput(ctx, + m.displayName, + "coatRoughness", + m.clearcoatRoughness, + coat.roughnessTexture, + AdobeTokens->g, + &coat.roughnessFactor); + importNormalInput( + ctx, m.displayName, "coatNormal", m.clearcoatNormal, coat.normalTexture); + importValue1(m.clearcoatIor, coat.ior); + importColorInput(ctx, + m.displayName, + "coatColor", + m.clearcoatColor, + coat.colorTexture, + coat.colorFactor, + 1.0); + } else if (importClearcoat(gm.extensions, &clearcoat)) { + importInput(ctx, + m.displayName, + "clearcoat", + m.clearcoat, + clearcoat.texture, + AdobeTokens->r, + &clearcoat.factor); + importInput(ctx, + m.displayName, + "clearcoatRoughness", + m.clearcoatRoughness, + clearcoat.roughnessTexture, + AdobeTokens->g, + &clearcoat.roughnessFactor); + importNormalInput(ctx, + m.displayName, + "clearcoatNormal", + m.clearcoatNormal, + clearcoat.normalTexture); + AdobeClearcoatSpecular clearcoatSpecular; + if (importAdobeClearcoatSpecular( + gm.extensions, &clearcoatSpecular, m.displayName)) { + importValue1(m.clearcoatIor, clearcoatSpecular.ior); + importInput(ctx, + m.displayName, + "clearcoatSpecular", + m.clearcoatSpecular, + clearcoatSpecular.texture, + AdobeTokens->b, + &clearcoatSpecular.factor, + 1.0); + } + AdobeClearcoatColor clearcoatColor; + if (importAdobeClearcoatColor(gm.extensions, &clearcoatColor)) { + importColorInput(ctx, + m.displayName, + "clearcoatColor", + m.clearcoatColor, + clearcoatColor.texture, + clearcoatColor.factor, + 1.0); + } + } + Sheen sheen; + if (importSheen(gm.extensions, &sheen)) { + importColorInput(ctx, + m.displayName, + "sheenColor", + m.sheenColor, + sheen.colorTexture, + sheen.colorFactor); + importInput(ctx, + m.displayName, + "sheenRoughness", + m.sheenRoughness, + sheen.roughnessTexture, + AdobeTokens->a, + &sheen.roughnessFactor); + } + Transmission transmission; + bool hasTransmission = false; + if (importTransmission(gm.extensions, &transmission)) { + importInput(ctx, + m.displayName, + "transmission", + m.transmission, + transmission.texture, + AdobeTokens->r, + &transmission.factor); + hasTransmission = true; + // Note, the GLTF material model uses the baseColor to tint transmission + // through a surface. To emulate that behavior with ASM 4.0 we try to map + // the baseColor to the clearcoatColor and activate the clearcoat. This + // becomes complicated if the clearcoat is already in use. We try our best + // below, but we're not trying to blend signals to make this work at all cost + if (isInputUsed(m.diffuseColor)) { + if (!isInputUsed(m.clearcoat)) { + // Use the transmission strength as the strength for the lobe + m.clearcoat = m.transmission; + // Transfer the values from the regular specular lobe + m.clearcoatRoughness = m.roughness; + m.clearcoatNormal = m.normal; + m.clearcoatSpecular = m.specularLevel; + m.clearcoatIor = m.ior; + if (!isInputUsed(m.clearcoatColor)) { + m.clearcoatColor = m.diffuseColor; + // Mark that material as having a specific purpose for the + // clearcoat that was not authored in the source asset + m.clearcoatModelsTransmissionTint = true; + } else { + TF_WARN( + "Can't map baseColor to clearcoatColor for transmission, since " + "clearcoatColor is in use, for material %s", + m.displayName.c_str()); + } + } else { + TF_DEBUG_MSG(FILE_FORMAT_GLTF, + "Can't touch clearcoat lobe to enable " + "transmission tinting on material %s\n", + m.displayName.c_str()); + } + } + } + DiffuseTransmission diffuseTransmission; + if (importDiffuseTransmission(gm.extensions, &diffuseTransmission)) { + // Note, the ASM 4.0 model does not have a diffuse transmission lobe, so + // we're approximating this effect by mapping it to general micro-facet + // transmission and volume absorption. Ideally we would make the micro-facet + // roughness very high to approach a diffuse transmission, but this would + // mess with general specular, so we're not changing roughness. + if (!hasTransmission) { + importInput(ctx, + m.displayName, + "transmission", + m.transmission, + diffuseTransmission.texture, + AdobeTokens->a, + &diffuseTransmission.factor); + importColorInput(ctx, + m.displayName, + "absorptionColor", + m.absorptionColor, + diffuseTransmission.colorTexture, + diffuseTransmission.colorFactor); + } else { + TF_WARN("Material %s has both KHR_materials_transmission and " + "KHR_materials_diffuse_transmission. Ignoring the latter.", + m.displayName.c_str()); + } + } + Volume volume; + if (importVolume(gm.extensions, &volume) && volume.thicknessFactor > 0.0) { + importInput(ctx, + m.displayName, + "thickness", + m.volumeThickness, + volume.thicknessTexture, + AdobeTokens->g, + &volume.thicknessFactor); + importValue1(m.absorptionDistance, volume.attenuationDistance); + // absorptionColor from the extension is a constant and we use it as a + // multiplier on the existing absorptionColor, which is often the same as + // diffuse + GfVec3f mult(volume.attenuationColor[0], + volume.attenuationColor[1], + volume.attenuationColor[2]); + applyInputMultiplier(m.absorptionColor, mult); + + // We only import volume scatter if we have volume already. + VolumeScatter volumeScatter; + if (importVolumeScatter(gm.extensions, &volumeScatter)) { + convertVolumeScatterToASM(ctx, volumeScatter, volume, m); + } else { + // Check for old, subsurface extension. + Subsurface subsurface; + if (importSubsurface(gm.extensions, &subsurface)) { + importValue1(m.scatteringDistance, subsurface.scatterDistance); + importValue3(m.scatteringColor, subsurface.scatterColor); + } + } + } + } + bool unlit = importUnlit(gm.extensions); + double emissiveStrength = 1.0; + importEmissionStrength(gm.extensions, &emissiveStrength); + if (gm.emissiveTexture.index >= 0) { + int imageIndex = + importImage(ctx, gm.emissiveTexture.index, m.displayName, "emissive"); + importTexture(ctx.gltf, + imageIndex, + gm.emissiveTexture.index, + gm.emissiveTexture.texCoord, + m.emissiveColor, + AdobeTokens->rgb, + AdobeTokens->sRGB); + importScale3(m.emissiveColor, gm.emissiveFactor.data(), emissiveStrength); + importTextureTransform(gm.emissiveTexture.extensions, m.emissiveColor); + } else if (gm.emissiveFactor.size() == 3 && + (gm.emissiveFactor[0] > 0 || gm.emissiveFactor[1] > 0 || + gm.emissiveFactor[2] > 0)) { + importValue3(m.emissiveColor, gm.emissiveFactor.data(), emissiveStrength); + } else if (unlit) { + m.emissiveColor = m.diffuseColor; + std::array black = { 0, 0, 0 }; + importValue3(m.diffuseColor, black.data()); + m.isUnlit = true; + } + if (gm.alphaMode == "MASK") { + importValue1(m.opacityThreshold, gm.alphaCutoff); + } + // Import normal map // Normal maps should not get the sRGB treatment and hence should be read as "raw" // 8-bit channel data - importTexture(ctx.gltf, - imageIndex, - gm.normalTexture.index, - gm.normalTexture.texCoord, - m.normal, - AdobeTokens->rgb, - AdobeTokens->raw); - importTextureTransform(gm.normalTexture.extensions, m.normal); - // normal.scale for 8-bit normal maps is 2,2,2,1 and normal.bias is -1,-1,-1, 0 - // We then incorporate the scale from the glTF normalTexture into the - // normal.scale and normal.bias. The official usdchecker will flag scale and bias - // that are not 2 and -1 for normal map texture readers: - // https://github.com/PixarAnimationStudios/USD/blob/release/pxr/usd/usdUtils/complianceChecker.py#L568 - float xyScale = 2.0f * gm.normalTexture.scale; - float xyBias = -1.0f * gm.normalTexture.scale; - m.normal.scale = GfVec4f(xyScale, xyScale, 2.0f, 1.0f); - m.normal.bias = GfVec4f(xyBias, xyBias, -1.0f, 0.0f); - importValue1(m.normalScale, gm.normalTexture.scale); - } - if (gm.occlusionTexture.index >= 0) { - int imageIndex = - importImage(ctx, gm.occlusionTexture.index, m.displayName, "occlusion"); - importTexture(ctx.gltf, - imageIndex, - gm.occlusionTexture.index, - gm.occlusionTexture.texCoord, - m.occlusion, - AdobeTokens->r, - AdobeTokens->raw); - importScale1(m.occlusion, gm.occlusionTexture.strength); - importTextureTransform(gm.occlusionTexture.extensions, m.occlusion); - } else if (gm.occlusionTexture.strength != 1.0) { - importValue1(m.occlusion, gm.occlusionTexture.strength); + if (gm.normalTexture.index >= 0) { + int imageIndex = importImage(ctx, gm.normalTexture.index, m.displayName, "normal"); + importTexture(ctx.gltf, + imageIndex, + gm.normalTexture.index, + gm.normalTexture.texCoord, + m.normal, + AdobeTokens->rgb, + AdobeTokens->raw); + importTextureTransform(gm.normalTexture.extensions, m.normal); + // normal.scale for 8-bit normal maps is 2,2,2,1 and normal.bias is -1,-1,-1, 0 + // We then incorporate the scale from the glTF normalTexture into the + // normal.scale and normal.bias. The official usdchecker will flag scale and bias + // that are not 2 and -1 for normal map texture readers: + // https://github.com/PixarAnimationStudios/USD/blob/release/pxr/usd/usdUtils/complianceChecker.py#L568 + float xyScale = 2.0f * gm.normalTexture.scale; + float xyBias = -1.0f * gm.normalTexture.scale; + m.normal.scale = GfVec4f(xyScale, xyScale, 2.0f, 1.0f); + m.normal.bias = GfVec4f(xyBias, xyBias, -1.0f, 0.0f); + importValue1(m.normalScale, gm.normalTexture.scale); + } + if (gm.occlusionTexture.index >= 0) { + int imageIndex = + importImage(ctx, gm.occlusionTexture.index, m.displayName, "occlusion"); + importTexture(ctx.gltf, + imageIndex, + gm.occlusionTexture.index, + gm.occlusionTexture.texCoord, + m.occlusion, + AdobeTokens->r, + AdobeTokens->raw); + importScale1(m.occlusion, gm.occlusionTexture.strength); + importTextureTransform(gm.occlusionTexture.extensions, m.occlusion); + } else if (gm.occlusionTexture.strength != 1.0) { + importValue1(m.occlusion, gm.occlusionTexture.strength); + } } } } @@ -1631,8 +2569,10 @@ importMeshJointWeights(const tinygltf::Model& model, if (numJointSets == 1) { readAccessorInts(model, jointsIndices[0], mesh.joints); - readAccessorDataToFloat( - model, weightsIndices[0], reinterpret_cast(mesh.weights.data())); + readAccessorDataToFloat(model, + weightsIndices[0], + reinterpret_cast(mesh.weights.data()), + mesh.weights.size()); } else { // read each pair of joint indices and weights PXR_NS::VtArray joints[MaxJointWeightSets]; @@ -1641,8 +2581,10 @@ importMeshJointWeights(const tinygltf::Model& model, joints[i].resize(vertexCount * 4); readAccessorInts(model, jointsIndices[i], joints[i]); weights[i].resize(vertexCount * 4); - readAccessorDataToFloat( - model, weightsIndices[i], reinterpret_cast(weights[i].data())); + readAccessorDataToFloat(model, + weightsIndices[i], + reinterpret_cast(weights[i].data()), + weights[i].size()); } // combine the 4 values of joint indices and weights for each set of values into a @@ -1694,6 +2636,43 @@ getIndices(const tinygltf::Model& model, } } +// Validate that a vertex-attribute accessor has the glTF type expected by its destination layout +// before its data is sized and read. The POSITION/NORMAL/TANGENT/TEXCOORD destination buffers are +// sized from the GLTF semantic (VEC3/VEC3/VEC4/VEC2), but readAccessorDataToFloat copies bytes +// according to the file-declared accessor.type; a mismatch (e.g. POSITION referencing a MAT4 +// accessor) overflows the destination. This mirrors the existing JOINTS/WEIGHTS validation. +static bool +validateAttributeAccessorType(const tinygltf::Model& model, + int accessorIndex, + int expectedType, + const char* semantic, + const std::string& meshName) +{ + if (accessorIndex < 0) + return false; + if (accessorIndex >= static_cast(model.accessors.size())) { + TF_WARN("%s accessor index %d out of bounds (length %zu) for mesh '%s'", + semantic, + accessorIndex, + model.accessors.size(), + meshName.c_str()); + return false; + } + const tinygltf::Accessor& accessor = model.accessors[accessorIndex]; + if (accessor.type != expectedType) { + TF_WARN( + "%s accessor %d has invalid type %d (expected %d) for mesh '%s'. Skipping attribute " + "to prevent buffer overflow.", + semantic, + accessorIndex, + accessor.type, + expectedType, + meshName.c_str()); + return false; + } + return true; +} + void importMeshes(ImportGltfContext& ctx) { @@ -1722,6 +2701,15 @@ importMeshes(ImportGltfContext& ctx) // Pre-validate indices before loading mesh data bool skipLoadingData = false; + + // POSITION must be VEC3; its accessor sizes mesh.points and is read as float. A + // mismatched accessor.type would overflow the destination, so skip the whole mesh. + if (positionsIndex >= 0 && + !validateAttributeAccessorType( + *ctx.gltf, positionsIndex, TINYGLTF_TYPE_VEC3, "POSITION", gmesh.name)) { + skipLoadingData = true; + } + if (indicesIndex >= 0) { PXR_NS::VtArray tempIndices; getIndices(*ctx.gltf, indicesIndex, vertexCount, tempIndices); @@ -1756,27 +2744,37 @@ importMeshes(ImportGltfContext& ctx) mesh.displayName = mesh.displayName + "_primitive" + std::to_string(j); } - // POSITION is required in GLTF + // POSITION is required in GLTF (accessor type validated as VEC3 above) mesh.points = PXR_NS::VtArray(getAccessorElementCount(*ctx.gltf, positionsIndex)); - readAccessorDataToFloat( - *ctx.gltf, positionsIndex, reinterpret_cast(mesh.points.data())); + readAccessorDataToFloat(*ctx.gltf, + positionsIndex, + reinterpret_cast(mesh.points.data()), + mesh.points.size() * 3); // NORMAL is optional - only read if present - if (normalsIndex >= 0) { + if (normalsIndex >= 0 && + validateAttributeAccessorType( + *ctx.gltf, normalsIndex, TINYGLTF_TYPE_VEC3, "NORMAL", mesh.displayName)) { mesh.normals.values = PXR_NS::VtArray( getAccessorElementCount(*ctx.gltf, normalsIndex)); - readAccessorDataToFloat( - *ctx.gltf, normalsIndex, reinterpret_cast(mesh.normals.values.data())); + readAccessorDataToFloat(*ctx.gltf, + normalsIndex, + reinterpret_cast(mesh.normals.values.data()), + mesh.normals.values.size() * 3); mesh.normals.interpolation = UsdGeomTokens->vertex; } // TANGENT is optional - only read if present - if (tangentsIndex >= 0) { + if (tangentsIndex >= 0 && + validateAttributeAccessorType( + *ctx.gltf, tangentsIndex, TINYGLTF_TYPE_VEC4, "TANGENT", mesh.displayName)) { mesh.tangents.values = PXR_NS::VtArray( getAccessorElementCount(*ctx.gltf, tangentsIndex)); - readAccessorDataToFloat( - *ctx.gltf, tangentsIndex, reinterpret_cast(mesh.tangents.values.data())); + readAccessorDataToFloat(*ctx.gltf, + tangentsIndex, + reinterpret_cast(mesh.tangents.values.data()), + mesh.tangents.values.size() * 4); mesh.tangents.interpolation = UsdGeomTokens->vertex; // GLTF tangent format: (x, y, z, w) where w is handedness (+1 or -1) @@ -1820,11 +2818,15 @@ importMeshes(ImportGltfContext& ctx) } // TEXCOORD_0 is optional - only read if present - if (uvsIndex >= 0) { + if (uvsIndex >= 0 && + validateAttributeAccessorType( + *ctx.gltf, uvsIndex, TINYGLTF_TYPE_VEC2, "TEXCOORD_0", mesh.displayName)) { mesh.uvs.values = PXR_NS::VtArray(getAccessorElementCount(*ctx.gltf, uvsIndex)); - readAccessorDataToFloat( - *ctx.gltf, uvsIndex, reinterpret_cast(mesh.uvs.values.data())); + readAccessorDataToFloat(*ctx.gltf, + uvsIndex, + reinterpret_cast(mesh.uvs.values.data()), + mesh.uvs.values.size() * 2); // Validate UV coordinates - clean out NaN/Inf values size_t invalidCount = 0; @@ -1860,13 +2862,24 @@ importMeshes(ImportGltfContext& ctx) if (uvsIndex < 0) break; + // Stop reading additional UV sets on a type mismatch; continuing would + // misalign extraUVSets indices and an invalid accessor.type would overflow. + if (!validateAttributeAccessorType(*ctx.gltf, + uvsIndex, + TINYGLTF_TYPE_VEC2, + ("TEXCOORD_" + std::to_string(n)).c_str(), + mesh.displayName)) + break; + // add a new primvar for the additional UV set mesh.extraUVSets.push_back(Primvar()); Primvar& uvs = mesh.extraUVSets[n - 1]; uvs.values = PXR_NS::VtArray( getAccessorElementCount(*ctx.gltf, uvsIndex)); - readAccessorDataToFloat( - *ctx.gltf, uvsIndex, reinterpret_cast(uvs.values.data())); + readAccessorDataToFloat(*ctx.gltf, + uvsIndex, + reinterpret_cast(uvs.values.data()), + uvs.values.size() * 2); // Validate UV coordinates for extra UV sets - clean out NaN/Inf values size_t invalidCount = 0; @@ -1972,7 +2985,7 @@ importMeshes(ImportGltfContext& ctx) opacityPV.interpolation = UsdGeomTokens->vertex; } if (primitive.material >= 0) { - if (ctx.gltf->materials.size() > primitive.material) { + if (static_cast(ctx.gltf->materials.size()) > primitive.material) { mesh.material = primitive.material; mesh.doubleSided = ctx.gltf->materials[primitive.material].doubleSided; } else { @@ -2009,7 +3022,7 @@ _buildSkeletonNodeNames(ImportGltfContext& ctx, ctx.skeletonNodeNames[nodeIndex] = name; // Then we'll check if the node index is valid - if (nodeIndex < 0 || nodeIndex >= ctx.gltf->nodes.size()) { + if (nodeIndex < 0 || static_cast(nodeIndex) >= ctx.gltf->nodes.size()) { TF_WARN("Node index %d out of bounds (length %zu)", nodeIndex, ctx.gltf->nodes.size()); // This is a bad node index, so we won't look for children. @@ -2189,7 +3202,8 @@ importSkeletons(ImportGltfContext& ctx) getAccessorElementCount(*ctx.gltf, skin.inverseBindMatrices)); readAccessorData(*ctx.gltf, skin.inverseBindMatrices, - reinterpret_cast(inverseBindMatricesFloat.data())); + reinterpret_cast(inverseBindMatricesFloat.data()), + inverseBindMatricesFloat.size() * sizeof(PXR_NS::GfMatrix4f)); for (size_t jointIdx = 0; jointIdx < skin.joints.size(); jointIdx++) { skeleton.bindTransforms[jointIdx] = PXR_NS::GfMatrix4d(inverseBindMatricesFloat[jointIdx]).GetInverse(); @@ -2301,12 +3315,37 @@ importChannel(const tinygltf::Model& gltf, return false; } + // Per the glTF 2.0 spec, an animation sampler's input and output accessors + // must have the same element count for STEP/LINEAR interpolation (we do not + // implement CUBICSPLINE here). Mismatched counts produce a TimeValues whose + // times and values arrays have different sizes, which downstream writers + // index into using times.size(), causing an out-of-bounds read on values. + if (count != count2) { + TF_WARN("Animation sampler input count %d does not match output count %d for " + "channel '%s'; rejecting sampler", + count, + count2, + name.c_str()); + return false; + } + values.times.resize(offset + count); values.values.resize(offset + count2); - readAccessorDataToFloat( - gltf, sampler.input, reinterpret_cast(values.times.data() + offset)); - readAccessorDataToFloat( - gltf, sampler.output, reinterpret_cast(values.values.data() + offset)); + + // Destination capacities (in floats) for the regions we're about to fill, starting at + // `offset`. times is a float array, so its capacity is the element count. values is an + // array of T, so each element holds sizeof(T)/sizeof(float) floats. + const size_t floatsPerValue = sizeof(T) / sizeof(float); + const size_t timesCapacityFloats = values.times.size() - offset; + const size_t valuesCapacityFloats = (values.values.size() - offset) * floatsPerValue; + readAccessorDataToFloat(gltf, + sampler.input, + reinterpret_cast(values.times.data() + offset), + timesCapacityFloats); + readAccessorDataToFloat(gltf, + sampler.output, + reinterpret_cast(values.values.data() + offset), + valuesCapacityFloats); // Safe to access array elements since we validated count > 0 minTime = std::min(minTime, values.times[offset]); @@ -2319,10 +3358,10 @@ importChannel(const tinygltf::Model& gltf, void importAnimationTracks(ImportGltfContext& ctx) { - int animationTrackCount = ctx.gltf->animations.size(); + size_t animationTrackCount = ctx.gltf->animations.size(); ctx.usd->animationTracks.resize(animationTrackCount); - for (int animationTrackIndex = 0; animationTrackIndex < animationTrackCount; + for (size_t animationTrackIndex = 0; animationTrackIndex < animationTrackCount; animationTrackIndex++) { const tinygltf::Animation& animation = ctx.gltf->animations[animationTrackIndex]; AnimationTrack& track = ctx.usd->animationTracks[animationTrackIndex]; @@ -2333,13 +3372,14 @@ importAnimationTracks(ImportGltfContext& ctx) void importNodeAnimations(ImportGltfContext& ctx) { - for (int animationTrackIndex = 0; animationTrackIndex < ctx.usd->animationTracks.size(); + for (size_t animationTrackIndex = 0; animationTrackIndex < ctx.usd->animationTracks.size(); animationTrackIndex++) { const tinygltf::Animation& animation = ctx.gltf->animations[animationTrackIndex]; AnimationTrack& track = ctx.usd->animationTracks[animationTrackIndex]; for (const tinygltf::AnimationChannel& channel : animation.channels) { - if (channel.sampler < 0 || channel.sampler >= animation.samplers.size()) { + if (channel.sampler < 0 || + static_cast(channel.sampler) >= animation.samplers.size()) { TF_WARN("Animation sampler index %d is out of bounds (max: %zu)", channel.sampler, animation.samplers.size()); @@ -2351,7 +3391,8 @@ importNodeAnimations(ImportGltfContext& ctx) TF_WARN("Could not find USD node index for glTF node %d", channel.target_node); continue; } - if (nodeIt->second < 0 || nodeIt->second >= ctx.usd->nodes.size()) { + if (nodeIt->second < 0 || + static_cast(nodeIt->second) >= ctx.usd->nodes.size()) { TF_WARN("USD node index %d out of bounds (length %zu)", nodeIt->second, ctx.usd->nodes.size()); @@ -2424,14 +3465,16 @@ importSkeletonAnimations(ImportGltfContext& ctx) TF_WARN("Could not find USD node index for glTF node %d", channel.target_node); continue; } - if (nodeIt->second < 0 || nodeIt->second >= ctx.usd->nodes.size()) { + if (nodeIt->second < 0 || + static_cast(nodeIt->second) >= ctx.usd->nodes.size()) { TF_WARN("USD node index %d out of bounds (length %zu)", nodeIt->second, ctx.usd->nodes.size()); continue; } if (!ctx.usd->nodes[nodeIt->second].isJoint) { - if (channel.target_node < 0 || channel.target_node >= ctx.gltf->nodes.size()) { + if (channel.target_node < 0 || + static_cast(channel.target_node) >= ctx.gltf->nodes.size()) { TF_WARN("Node index %d out of bounds (length %zu)", channel.target_node, ctx.gltf->nodes.size()); @@ -2509,7 +3552,8 @@ importSkeletonAnimations(ImportGltfContext& ctx) TF_WARN("Could not find USD node index for glTF node %d", animNode); continue; } - if (nodeIt->second < 0 || nodeIt->second >= ctx.usd->nodes.size()) { + if (nodeIt->second < 0 || + static_cast(nodeIt->second) >= ctx.usd->nodes.size()) { TF_WARN("USD node index %d out of bounds (length %zu)", nodeIt->second, ctx.usd->nodes.size()); @@ -2555,7 +3599,8 @@ importSkeletonAnimations(ImportGltfContext& ctx) TF_WARN("Could not find USD node index for glTF node %d", nodeIndex); continue; } - if (nodeIt->second < 0 || nodeIt->second >= ctx.usd->nodes.size()) { + if (nodeIt->second < 0 || + static_cast(nodeIt->second) >= ctx.usd->nodes.size()) { TF_WARN("USD node index %d out of bounds (length %zu)", nodeIt->second, ctx.usd->nodes.size()); @@ -2563,7 +3608,7 @@ importSkeletonAnimations(ImportGltfContext& ctx) } const Node& n = ctx.usd->nodes[nodeIt->second]; - if (nodeIndex < 0 || nodeIndex >= ctx.gltf->nodes.size()) { + if (nodeIndex < 0 || static_cast(nodeIndex) >= ctx.gltf->nodes.size()) { TF_WARN("Node index %d out of bounds (length %zu)", nodeIndex, ctx.gltf->nodes.size()); @@ -2845,7 +3890,7 @@ _traverseNodes(ImportGltfContext& ctx, int usdNodeIndex = curUsdIndex; curUsdIndex++; - if (usdNodeIndex < 0 || usdNodeIndex >= ctx.usd->nodes.size()) { + if (usdNodeIndex < 0 || static_cast(usdNodeIndex) >= ctx.usd->nodes.size()) { // You're trying to process a node that we haven't processed, but // we don't have any more space in the usd nodes vector? That shouldn't happen. // This must be a malformed gltf file. The number of usd nodes is set @@ -2868,7 +3913,7 @@ _traverseNodes(ImportGltfContext& ctx, } } - if (nodeIndex < 0 || nodeIndex >= ctx.gltf->nodes.size()) { + if (nodeIndex < 0 || static_cast(nodeIndex) >= ctx.gltf->nodes.size()) { TF_WARN("Node index %d is out of bounds (max: %zu)", nodeIndex, ctx.gltf->nodes.size()); // There's a bad node index, but to preserve the mapping, we'll create a placeholder node @@ -2943,7 +3988,7 @@ _traverseNodes(ImportGltfContext& ctx, } } // Validate light index before use - if (node.light >= 0) { + if (node.light >= 0 && ctx.options->importLights) { if (static_cast(node.light) >= ctx.gltf->lights.size()) { TF_WARN("Node '%s' references invalid light index %d (max: %zu)", node.name.c_str(), @@ -2990,7 +4035,7 @@ _traverseNodes(ImportGltfContext& ctx, if (traversedNodes.count(childIndex) > 0) { continue; // No loops } - if (childIndex < 0 || childIndex >= ctx.gltf->nodes.size()) { + if (childIndex < 0 || static_cast(childIndex) >= ctx.gltf->nodes.size()) { continue; // No bad indices } @@ -3053,11 +4098,11 @@ importNodes(ImportGltfContext& ctx) int gltfSkinRootNodexIndex = nodeIndex; - if (node.skin < 0 || node.skin >= ctx.gltf->skins.size()) { + if (node.skin < 0 || static_cast(node.skin) >= ctx.gltf->skins.size()) { TF_WARN("Skin index %d is out of bounds (max: %zu)", node.skin, ctx.gltf->skins.size()); continue; } - if (node.mesh < 0 || node.mesh >= ctx.meshes.size()) { + if (node.mesh < 0 || static_cast(node.mesh) >= ctx.meshes.size()) { TF_WARN("Mesh index %d is out of bounds (max: %zu)", node.mesh, ctx.meshes.size()); continue; } @@ -3116,7 +4161,8 @@ checkMeshInstancing(ImportGltfContext& ctx) if (useCount > 1) { const std::vector& meshPrimitiveIndices = ctx.meshes[meshIdx]; for (int primitiveIdx : meshPrimitiveIndices) { - if (primitiveIdx < 0 || primitiveIdx >= ctx.usd->meshes.size()) { + if (primitiveIdx < 0 || + static_cast(primitiveIdx) >= ctx.usd->meshes.size()) { TF_WARN("Primitive index %d is out of bounds (max: %zu)", primitiveIdx, ctx.usd->meshes.size()); @@ -3140,9 +4186,10 @@ static const std::set supportedExtension = { "KHR_lights_punctual", "KHR_materials_anisotropy", "KHR_materials_clearcoat", + "KHR_materials_dispersion", "KHR_materials_emissive_strength", "KHR_materials_ior", - // "KHR_materials_iridescence", + "KHR_materials_iridescence", "KHR_materials_sheen", "KHR_materials_specular", "KHR_materials_transmission", @@ -3170,6 +4217,10 @@ static const std::set supportedExtension = { "KHR_materials_diffuse_transmission", "KHR_materials_volume_scatter", "KHR_materials_coat", + "KHR_materials_fuzz", + "KHR_materials_diffuse_roughness", + + // Deprecated in-progress extensions "KHR_materials_subsurface", // previous incarnation of KHR_materials_volume_scatter "KHR_materials_sss" // previous name of KHR_materials_subsurface }; @@ -3246,9 +4297,12 @@ importGltf(const ImportGltfOptions& options, importMaterials(ctx); TF_DEBUG_MSG(FILE_FORMAT_GLTF, "Materials import completed successfully\n"); } - if (options.importGeometry) { + if (options.importLights) { TF_DEBUG_MSG(FILE_FORMAT_GLTF, "Starting lights import...\n"); importLights(ctx); + TF_DEBUG_MSG(FILE_FORMAT_GLTF, "Lights import completed\n"); + } + if (options.importGeometry) { TF_DEBUG_MSG(FILE_FORMAT_GLTF, "Starting meshes import...\n"); importMeshes(ctx); TF_DEBUG_MSG(FILE_FORMAT_GLTF, "Meshes import completed\n"); diff --git a/gltf/src/gltfImport.h b/gltf/src/gltfImport.h index 247b85b7..b3a58d8f 100644 --- a/gltf/src/gltfImport.h +++ b/gltf/src/gltfImport.h @@ -22,6 +22,7 @@ struct ImportGltfOptions bool importGeometry = true; bool importMaterials = true; bool importImages = true; + bool importLights = true; bool computeBitangents = false; }; diff --git a/gltf/src/plugInfo.json.in b/gltf/src/plugInfo.json.in index 5056b62c..0e1362ce 100644 --- a/gltf/src/plugInfo.json.in +++ b/gltf/src/plugInfo.json.in @@ -31,7 +31,7 @@ } } }, - "LibraryPath": "${PLUG_INFO_LIBRARY_PATH}", + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", "Name": "usdGltf_plugin", "ResourcePath": "resources", "Root": "..", diff --git a/gltf/tests/CMakeLists.txt b/gltf/tests/CMakeLists.txt index d63856fd..18d8ef8e 100644 --- a/gltf/tests/CMakeLists.txt +++ b/gltf/tests/CMakeLists.txt @@ -7,8 +7,12 @@ usd_plugin_compile_config(gltfSanityTests) target_link_libraries(gltfSanityTests PRIVATE usd + usdShade GTest::gtest GTest::gtest_main + fileformatUtilsTest + gtestCommon + nlohmann_json::nlohmann_json ) gtest_add_tests(TARGET gltfSanityTests AUTO) @@ -16,3 +20,14 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/SanityCube.gltf" "${CMAKE_CURRENT_BI configure_file("${CMAKE_CURRENT_SOURCE_DIR}/Cube.bin" "${CMAKE_CURRENT_BINARY_DIR}/Cube.bin" COPYONLY) configure_file("${CMAKE_CURRENT_SOURCE_DIR}/Cube_BaseColor.png" "${CMAKE_CURRENT_BINARY_DIR}/Cube_BaseColor.png" COPYONLY) configure_file("${CMAKE_CURRENT_SOURCE_DIR}/Cube_MetallicRoughness.png" "${CMAKE_CURRENT_BINARY_DIR}/Cube_MetallicRoughness.png" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/TransmissionThinWalled.gltf" "${CMAKE_CURRENT_BINARY_DIR}/TransmissionThinWalled.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/TransmissionVolumetric.gltf" "${CMAKE_CURRENT_BINARY_DIR}/TransmissionVolumetric.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/ExtCoat.gltf" "${CMAKE_CURRENT_BINARY_DIR}/ExtCoat.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/ExtDiffuseRoughness.gltf" "${CMAKE_CURRENT_BINARY_DIR}/ExtDiffuseRoughness.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/ExtDispersion.gltf" "${CMAKE_CURRENT_BINARY_DIR}/ExtDispersion.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/ExtFuzz.gltf" "${CMAKE_CURRENT_BINARY_DIR}/ExtFuzz.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/ExtIridescence.gltf" "${CMAKE_CURRENT_BINARY_DIR}/ExtIridescence.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/ExtCoatSimple.gltf" "${CMAKE_CURRENT_BINARY_DIR}/ExtCoatSimple.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/ExtVolumeScatterTransmission.gltf" "${CMAKE_CURRENT_BINARY_DIR}/ExtVolumeScatterTransmission.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/ExtVolumeScatterDiffuseTransmission.gltf" "${CMAKE_CURRENT_BINARY_DIR}/ExtVolumeScatterDiffuseTransmission.gltf" COPYONLY) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/openpbr_base_weight.usda" "${CMAKE_CURRENT_BINARY_DIR}/openpbr_base_weight.usda" COPYONLY) diff --git a/gltf/tests/ExtCoat.gltf b/gltf/tests/ExtCoat.gltf new file mode 100644 index 00000000..7a92b3d5 --- /dev/null +++ b/gltf/tests/ExtCoat.gltf @@ -0,0 +1,49 @@ +{ + "asset": { "version": "2.0" }, + "extensionsUsed": [ "KHR_materials_coat" ], + "accessors": [ + { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35] }, + { "bufferView": 1, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.000001] }, + { "bufferView": 2, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.0] }, + { "bufferView": 3, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC4", "min": [0.0, 0.0, -1.0, -1.0], "max": [1.0, 0.0, 0.0, 1.0] }, + { "bufferView": 4, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC2", "min": [-1.0, -1.0], "max": [1.0, 1.0] } + ], + "bufferViews": [ + { "buffer": 0, "byteLength": 72, "byteOffset": 0, "target": 34963 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 72, "target": 34962 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 504, "target": 34962 }, + { "buffer": 0, "byteLength": 576, "byteOffset": 936, "target": 34962 }, + { "buffer": 0, "byteLength": 288, "byteOffset": 1512, "target": 34962 } + ], + "buffers": [ { "byteLength": 1800, "uri": "Cube.bin" } ], + "materials": [ + { + "name": "CoatMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": [ 0.2, 0.4, 0.8, 1.0 ], + "metallicFactor": 0.0, + "roughnessFactor": 0.5 + }, + "extensions": { + "KHR_materials_coat": { + "coatFactor": 0.7, + "coatRoughnessFactor": 0.3, + "coatIor": 1.8, + "coatColorFactor": [ 0.9, 0.9, 0.9 ] + } + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ { + "attributes": { "NORMAL": 2, "POSITION": 1, "TANGENT": 3, "TEXCOORD_0": 4 }, + "indices": 0, "material": 0, "mode": 4 + } ] + } + ], + "nodes": [ { "mesh": 0, "name": "Cube" } ], + "scene": 0, + "scenes": [ { "nodes": [ 0 ] } ] +} diff --git a/gltf/tests/ExtCoatSimple.gltf b/gltf/tests/ExtCoatSimple.gltf new file mode 100644 index 00000000..7590134b --- /dev/null +++ b/gltf/tests/ExtCoatSimple.gltf @@ -0,0 +1,49 @@ +{ + "asset": { "version": "2.0" }, + "extensionsUsed": [ "KHR_materials_coat" ], + "accessors": [ + { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35] }, + { "bufferView": 1, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.000001] }, + { "bufferView": 2, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.0] }, + { "bufferView": 3, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC4", "min": [0.0, 0.0, -1.0, -1.0], "max": [1.0, 0.0, 0.0, 1.0] }, + { "bufferView": 4, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC2", "min": [-1.0, -1.0], "max": [1.0, 1.0] } + ], + "bufferViews": [ + { "buffer": 0, "byteLength": 72, "byteOffset": 0, "target": 34963 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 72, "target": 34962 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 504, "target": 34962 }, + { "buffer": 0, "byteLength": 576, "byteOffset": 936, "target": 34962 }, + { "buffer": 0, "byteLength": 288, "byteOffset": 1512, "target": 34962 } + ], + "buffers": [ { "byteLength": 1800, "uri": "Cube.bin" } ], + "materials": [ + { + "name": "CoatSimpleMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": [ 0.2, 0.4, 0.8, 1.0 ], + "metallicFactor": 0.0, + "roughnessFactor": 0.5 + }, + "extensions": { + "KHR_materials_coat": { + "coatFactor": 0.5, + "coatRoughnessFactor": 0.2, + "coatIor": 1.5, + "coatDarkeningFactor": 0.0 + } + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ { + "attributes": { "NORMAL": 2, "POSITION": 1, "TANGENT": 3, "TEXCOORD_0": 4 }, + "indices": 0, "material": 0, "mode": 4 + } ] + } + ], + "nodes": [ { "mesh": 0, "name": "Cube" } ], + "scene": 0, + "scenes": [ { "nodes": [ 0 ] } ] +} diff --git a/gltf/tests/ExtDiffuseRoughness.gltf b/gltf/tests/ExtDiffuseRoughness.gltf new file mode 100644 index 00000000..4ed5c4ca --- /dev/null +++ b/gltf/tests/ExtDiffuseRoughness.gltf @@ -0,0 +1,46 @@ +{ + "asset": { "version": "2.0" }, + "extensionsUsed": [ "KHR_materials_diffuse_roughness" ], + "accessors": [ + { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35] }, + { "bufferView": 1, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.000001] }, + { "bufferView": 2, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.0] }, + { "bufferView": 3, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC4", "min": [0.0, 0.0, -1.0, -1.0], "max": [1.0, 0.0, 0.0, 1.0] }, + { "bufferView": 4, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC2", "min": [-1.0, -1.0], "max": [1.0, 1.0] } + ], + "bufferViews": [ + { "buffer": 0, "byteLength": 72, "byteOffset": 0, "target": 34963 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 72, "target": 34962 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 504, "target": 34962 }, + { "buffer": 0, "byteLength": 576, "byteOffset": 936, "target": 34962 }, + { "buffer": 0, "byteLength": 288, "byteOffset": 1512, "target": 34962 } + ], + "buffers": [ { "byteLength": 1800, "uri": "Cube.bin" } ], + "materials": [ + { + "name": "DiffuseRoughnessMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": [ 0.5, 0.5, 0.5, 1.0 ], + "metallicFactor": 0.0, + "roughnessFactor": 0.5 + }, + "extensions": { + "KHR_materials_diffuse_roughness": { + "diffuseRoughnessFactor": 0.4 + } + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ { + "attributes": { "NORMAL": 2, "POSITION": 1, "TANGENT": 3, "TEXCOORD_0": 4 }, + "indices": 0, "material": 0, "mode": 4 + } ] + } + ], + "nodes": [ { "mesh": 0, "name": "Cube" } ], + "scene": 0, + "scenes": [ { "nodes": [ 0 ] } ] +} diff --git a/gltf/tests/ExtDispersion.gltf b/gltf/tests/ExtDispersion.gltf new file mode 100644 index 00000000..f8c50a07 --- /dev/null +++ b/gltf/tests/ExtDispersion.gltf @@ -0,0 +1,54 @@ +{ + "asset": { "version": "2.0" }, + "extensionsUsed": [ "KHR_materials_transmission", "KHR_materials_dispersion", "KHR_materials_volume" ], + "accessors": [ + { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35] }, + { "bufferView": 1, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.000001] }, + { "bufferView": 2, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.0] }, + { "bufferView": 3, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC4", "min": [0.0, 0.0, -1.0, -1.0], "max": [1.0, 0.0, 0.0, 1.0] }, + { "bufferView": 4, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC2", "min": [-1.0, -1.0], "max": [1.0, 1.0] } + ], + "bufferViews": [ + { "buffer": 0, "byteLength": 72, "byteOffset": 0, "target": 34963 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 72, "target": 34962 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 504, "target": 34962 }, + { "buffer": 0, "byteLength": 576, "byteOffset": 936, "target": 34962 }, + { "buffer": 0, "byteLength": 288, "byteOffset": 1512, "target": 34962 } + ], + "buffers": [ { "byteLength": 1800, "uri": "Cube.bin" } ], + "materials": [ + { + "name": "DispersionMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": [ 1.0, 1.0, 1.0, 1.0 ], + "metallicFactor": 0.0, + "roughnessFactor": 0.0 + }, + "extensions": { + "KHR_materials_transmission": { + "transmissionFactor": 1.0 + }, + "KHR_materials_volume": { + "thicknessFactor": 1.0, + "attenuationDistance": 1.0, + "attenuationColor": [ 1.0, 1.0, 1.0 ] + }, + "KHR_materials_dispersion": { + "dispersion": 0.05 + } + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ { + "attributes": { "NORMAL": 2, "POSITION": 1, "TANGENT": 3, "TEXCOORD_0": 4 }, + "indices": 0, "material": 0, "mode": 4 + } ] + } + ], + "nodes": [ { "mesh": 0, "name": "Cube" } ], + "scene": 0, + "scenes": [ { "nodes": [ 0 ] } ] +} diff --git a/gltf/tests/ExtFuzz.gltf b/gltf/tests/ExtFuzz.gltf new file mode 100644 index 00000000..967a96f5 --- /dev/null +++ b/gltf/tests/ExtFuzz.gltf @@ -0,0 +1,48 @@ +{ + "asset": { "version": "2.0" }, + "extensionsUsed": [ "KHR_materials_fuzz" ], + "accessors": [ + { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35] }, + { "bufferView": 1, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.000001] }, + { "bufferView": 2, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.0] }, + { "bufferView": 3, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC4", "min": [0.0, 0.0, -1.0, -1.0], "max": [1.0, 0.0, 0.0, 1.0] }, + { "bufferView": 4, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC2", "min": [-1.0, -1.0], "max": [1.0, 1.0] } + ], + "bufferViews": [ + { "buffer": 0, "byteLength": 72, "byteOffset": 0, "target": 34963 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 72, "target": 34962 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 504, "target": 34962 }, + { "buffer": 0, "byteLength": 576, "byteOffset": 936, "target": 34962 }, + { "buffer": 0, "byteLength": 288, "byteOffset": 1512, "target": 34962 } + ], + "buffers": [ { "byteLength": 1800, "uri": "Cube.bin" } ], + "materials": [ + { + "name": "FuzzMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": [ 0.5, 0.5, 0.5, 1.0 ], + "metallicFactor": 0.0, + "roughnessFactor": 0.8 + }, + "extensions": { + "KHR_materials_fuzz": { + "fuzzFactor": 0.8, + "fuzzColorFactor": [ 0.9, 0.5, 0.2 ], + "fuzzRoughnessFactor": 0.6 + } + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ { + "attributes": { "NORMAL": 2, "POSITION": 1, "TANGENT": 3, "TEXCOORD_0": 4 }, + "indices": 0, "material": 0, "mode": 4 + } ] + } + ], + "nodes": [ { "mesh": 0, "name": "Cube" } ], + "scene": 0, + "scenes": [ { "nodes": [ 0 ] } ] +} diff --git a/gltf/tests/ExtIridescence.gltf b/gltf/tests/ExtIridescence.gltf new file mode 100644 index 00000000..5ddb49c6 --- /dev/null +++ b/gltf/tests/ExtIridescence.gltf @@ -0,0 +1,48 @@ +{ + "asset": { "version": "2.0" }, + "extensionsUsed": [ "KHR_materials_iridescence" ], + "accessors": [ + { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35] }, + { "bufferView": 1, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.000001] }, + { "bufferView": 2, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.0] }, + { "bufferView": 3, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC4", "min": [0.0, 0.0, -1.0, -1.0], "max": [1.0, 0.0, 0.0, 1.0] }, + { "bufferView": 4, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC2", "min": [-1.0, -1.0], "max": [1.0, 1.0] } + ], + "bufferViews": [ + { "buffer": 0, "byteLength": 72, "byteOffset": 0, "target": 34963 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 72, "target": 34962 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 504, "target": 34962 }, + { "buffer": 0, "byteLength": 576, "byteOffset": 936, "target": 34962 }, + { "buffer": 0, "byteLength": 288, "byteOffset": 1512, "target": 34962 } + ], + "buffers": [ { "byteLength": 1800, "uri": "Cube.bin" } ], + "materials": [ + { + "name": "IridescenceMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": [ 0.8, 0.8, 0.8, 1.0 ], + "metallicFactor": 1.0, + "roughnessFactor": 0.1 + }, + "extensions": { + "KHR_materials_iridescence": { + "iridescenceFactor": 0.75, + "iridescenceIor": 1.5, + "iridescenceThicknessMaximum": 500.0 + } + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ { + "attributes": { "NORMAL": 2, "POSITION": 1, "TANGENT": 3, "TEXCOORD_0": 4 }, + "indices": 0, "material": 0, "mode": 4 + } ] + } + ], + "nodes": [ { "mesh": 0, "name": "Cube" } ], + "scene": 0, + "scenes": [ { "nodes": [ 0 ] } ] +} diff --git a/gltf/tests/ExtVolumeScatterDiffuseTransmission.gltf b/gltf/tests/ExtVolumeScatterDiffuseTransmission.gltf new file mode 100644 index 00000000..e431959f --- /dev/null +++ b/gltf/tests/ExtVolumeScatterDiffuseTransmission.gltf @@ -0,0 +1,57 @@ +{ + "asset": { "version": "2.0" }, + "extensionsUsed": [ "KHR_materials_diffuse_transmission", "KHR_materials_volume", "KHR_materials_volume_scatter" ], + "accessors": [ + { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35] }, + { "bufferView": 1, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.000001] }, + { "bufferView": 2, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.0] }, + { "bufferView": 3, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC4", "min": [0.0, 0.0, -1.0, -1.0], "max": [1.0, 0.0, 0.0, 1.0] }, + { "bufferView": 4, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC2", "min": [-1.0, -1.0], "max": [1.0, 1.0] } + ], + "bufferViews": [ + { "buffer": 0, "byteLength": 72, "byteOffset": 0, "target": 34963 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 72, "target": 34962 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 504, "target": 34962 }, + { "buffer": 0, "byteLength": 576, "byteOffset": 936, "target": 34962 }, + { "buffer": 0, "byteLength": 288, "byteOffset": 1512, "target": 34962 } + ], + "buffers": [ { "byteLength": 1800, "uri": "Cube.bin" } ], + "images": [ { "uri": "Cube_BaseColor.png" } ], + "textures": [ { "source": 0 } ], + "materials": [ + { + "name": "VolumeScatterDiffuseTransmissionMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": [ 1.0, 1.0, 1.0, 1.0 ], + "metallicFactor": 0.0, + "roughnessFactor": 0.5 + }, + "extensions": { + "KHR_materials_diffuse_transmission": { + "diffuseTransmissionFactor": 1.0 + }, + "KHR_materials_volume": { + "thicknessFactor": 1.0, + "attenuationDistance": 1.0, + "attenuationColor": [ 0.8, 0.8, 0.8 ] + }, + "KHR_materials_volume_scatter": { + "multiscatterColorFactor": [ 0.5, 0.7, 0.9 ], + "multiscatterColorTexture": { "index": 0 } + } + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ { + "attributes": { "NORMAL": 2, "POSITION": 1, "TANGENT": 3, "TEXCOORD_0": 4 }, + "indices": 0, "material": 0, "mode": 4 + } ] + } + ], + "nodes": [ { "mesh": 0, "name": "Cube" } ], + "scene": 0, + "scenes": [ { "nodes": [ 0 ] } ] +} diff --git a/gltf/tests/ExtVolumeScatterTransmission.gltf b/gltf/tests/ExtVolumeScatterTransmission.gltf new file mode 100644 index 00000000..ceccdeba --- /dev/null +++ b/gltf/tests/ExtVolumeScatterTransmission.gltf @@ -0,0 +1,57 @@ +{ + "asset": { "version": "2.0" }, + "extensionsUsed": [ "KHR_materials_transmission", "KHR_materials_volume", "KHR_materials_volume_scatter" ], + "accessors": [ + { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35] }, + { "bufferView": 1, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.000001] }, + { "bufferView": 2, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1.0, -1.0, -1.0], "max": [1.0, 1.0, 1.0] }, + { "bufferView": 3, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC4", "min": [0.0, 0.0, -1.0, -1.0], "max": [1.0, 0.0, 0.0, 1.0] }, + { "bufferView": 4, "byteOffset": 0, "componentType": 5126, "count": 36, "type": "VEC2", "min": [-1.0, -1.0], "max": [1.0, 1.0] } + ], + "bufferViews": [ + { "buffer": 0, "byteLength": 72, "byteOffset": 0, "target": 34963 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 72, "target": 34962 }, + { "buffer": 0, "byteLength": 432, "byteOffset": 504, "target": 34962 }, + { "buffer": 0, "byteLength": 576, "byteOffset": 936, "target": 34962 }, + { "buffer": 0, "byteLength": 288, "byteOffset": 1512, "target": 34962 } + ], + "buffers": [ { "byteLength": 1800, "uri": "Cube.bin" } ], + "images": [ { "uri": "Cube_BaseColor.png" } ], + "textures": [ { "source": 0 } ], + "materials": [ + { + "name": "VolumeScatterTransmissionMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": [ 1.0, 1.0, 1.0, 1.0 ], + "metallicFactor": 0.0, + "roughnessFactor": 0.0 + }, + "extensions": { + "KHR_materials_transmission": { + "transmissionFactor": 1.0 + }, + "KHR_materials_volume": { + "thicknessFactor": 1.0, + "attenuationDistance": 1.0, + "attenuationColor": [ 0.8, 0.8, 0.8 ] + }, + "KHR_materials_volume_scatter": { + "multiscatterColorFactor": [ 0.5, 0.7, 0.9 ], + "multiscatterColorTexture": { "index": 0 } + } + } + } + ], + "meshes": [ + { + "name": "Cube", + "primitives": [ { + "attributes": { "NORMAL": 2, "POSITION": 1, "TANGENT": 3, "TEXCOORD_0": 4 }, + "indices": 0, "material": 0, "mode": 4 + } ] + } + ], + "nodes": [ { "mesh": 0, "name": "Cube" } ], + "scene": 0, + "scenes": [ { "nodes": [ 0 ] } ] +} diff --git a/gltf/tests/TransmissionThinWalled.gltf b/gltf/tests/TransmissionThinWalled.gltf new file mode 100644 index 00000000..d660ace8 --- /dev/null +++ b/gltf/tests/TransmissionThinWalled.gltf @@ -0,0 +1,103 @@ +{ + "accessors" : [ + { + "bufferView" : 0, + "byteOffset" : 0, + "componentType" : 5123, + "count" : 36, + "max" : [ 35 ], + "min" : [ 0 ], + "type" : "SCALAR" + }, + { + "bufferView" : 1, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 36, + "max" : [ 1.0, 1.0, 1.000001 ], + "min" : [ -1.0, -1.0, -1.0 ], + "type" : "VEC3" + }, + { + "bufferView" : 2, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 36, + "max" : [ 1.0, 1.0, 1.0 ], + "min" : [ -1.0, -1.0, -1.0 ], + "type" : "VEC3" + }, + { + "bufferView" : 3, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 36, + "max" : [ 1.0, 0.0, 0.0, 1.0 ], + "min" : [ 0.0, 0.0, -1.0, -1.0 ], + "type" : "VEC4" + }, + { + "bufferView" : 4, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 36, + "max" : [ 1.0, 1.0 ], + "min" : [ -1.0, -1.0 ], + "type" : "VEC2" + } + ], + "asset" : { + "version" : "2.0" + }, + "bufferViews" : [ + { "buffer" : 0, "byteLength" : 72, "byteOffset" : 0, "target" : 34963 }, + { "buffer" : 0, "byteLength" : 432, "byteOffset" : 72, "target" : 34962 }, + { "buffer" : 0, "byteLength" : 432, "byteOffset" : 504, "target" : 34962 }, + { "buffer" : 0, "byteLength" : 576, "byteOffset" : 936, "target" : 34962 }, + { "buffer" : 0, "byteLength" : 288, "byteOffset" : 1512, "target" : 34962 } + ], + "buffers" : [ + { "byteLength" : 1800, "uri" : "Cube.bin" } + ], + "extensionsUsed" : [ "KHR_materials_transmission" ], + "materials" : [ + { + "name" : "TransmissionThinWalledMaterial", + "pbrMetallicRoughness" : { + "baseColorFactor" : [ 0.8, 0.3, 0.1, 1.0 ], + "metallicFactor" : 0.0, + "roughnessFactor" : 0.5 + }, + "extensions" : { + "KHR_materials_transmission" : { + "transmissionFactor" : 0.9 + } + } + } + ], + "meshes" : [ + { + "name" : "Cube", + "primitives" : [ + { + "attributes" : { + "NORMAL" : 2, + "POSITION" : 1, + "TANGENT" : 3, + "TEXCOORD_0" : 4 + }, + "indices" : 0, + "material" : 0, + "mode" : 4 + } + ] + } + ], + "nodes" : [ + { "mesh" : 0, "name" : "Cube" } + ], + "scene" : 0, + "scenes" : [ + { "nodes" : [ 0 ] } + ] +} diff --git a/gltf/tests/TransmissionVolumetric.gltf b/gltf/tests/TransmissionVolumetric.gltf new file mode 100644 index 00000000..04cc4af7 --- /dev/null +++ b/gltf/tests/TransmissionVolumetric.gltf @@ -0,0 +1,108 @@ +{ + "accessors" : [ + { + "bufferView" : 0, + "byteOffset" : 0, + "componentType" : 5123, + "count" : 36, + "max" : [ 35 ], + "min" : [ 0 ], + "type" : "SCALAR" + }, + { + "bufferView" : 1, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 36, + "max" : [ 1.0, 1.0, 1.000001 ], + "min" : [ -1.0, -1.0, -1.0 ], + "type" : "VEC3" + }, + { + "bufferView" : 2, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 36, + "max" : [ 1.0, 1.0, 1.0 ], + "min" : [ -1.0, -1.0, -1.0 ], + "type" : "VEC3" + }, + { + "bufferView" : 3, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 36, + "max" : [ 1.0, 0.0, 0.0, 1.0 ], + "min" : [ 0.0, 0.0, -1.0, -1.0 ], + "type" : "VEC4" + }, + { + "bufferView" : 4, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 36, + "max" : [ 1.0, 1.0 ], + "min" : [ -1.0, -1.0 ], + "type" : "VEC2" + } + ], + "asset" : { + "version" : "2.0" + }, + "bufferViews" : [ + { "buffer" : 0, "byteLength" : 72, "byteOffset" : 0, "target" : 34963 }, + { "buffer" : 0, "byteLength" : 432, "byteOffset" : 72, "target" : 34962 }, + { "buffer" : 0, "byteLength" : 432, "byteOffset" : 504, "target" : 34962 }, + { "buffer" : 0, "byteLength" : 576, "byteOffset" : 936, "target" : 34962 }, + { "buffer" : 0, "byteLength" : 288, "byteOffset" : 1512, "target" : 34962 } + ], + "buffers" : [ + { "byteLength" : 1800, "uri" : "Cube.bin" } + ], + "extensionsUsed" : [ "KHR_materials_transmission", "KHR_materials_volume" ], + "materials" : [ + { + "name" : "TransmissionVolumetricMaterial", + "pbrMetallicRoughness" : { + "baseColorFactor" : [ 0.8, 0.3, 0.1, 1.0 ], + "metallicFactor" : 0.0, + "roughnessFactor" : 0.5 + }, + "extensions" : { + "KHR_materials_transmission" : { + "transmissionFactor" : 0.9 + }, + "KHR_materials_volume" : { + "thicknessFactor" : 1.0, + "attenuationDistance" : 2.0, + "attenuationColor" : [ 1.0, 1.0, 1.0 ] + } + } + } + ], + "meshes" : [ + { + "name" : "Cube", + "primitives" : [ + { + "attributes" : { + "NORMAL" : 2, + "POSITION" : 1, + "TANGENT" : 3, + "TEXCOORD_0" : 4 + }, + "indices" : 0, + "material" : 0, + "mode" : 4 + } + ] + } + ], + "nodes" : [ + { "mesh" : 0, "name" : "Cube" } + ], + "scene" : 0, + "scenes" : [ + { "nodes" : [ 0 ] } + ] +} diff --git a/gltf/tests/openpbr_base_weight.usda b/gltf/tests/openpbr_base_weight.usda new file mode 100644 index 00000000..f0460f70 --- /dev/null +++ b/gltf/tests/openpbr_base_weight.usda @@ -0,0 +1,76 @@ +#usda 1.0 +( + defaultPrim = "OpenPbrBaseWeight" + metersPerUnit = 0.01 + upAxis = "Y" +) + +def Xform "OpenPbrBaseWeight" +{ + def Mesh "WeightedTriangle" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + float3[] extent = [(0, 0, 0), (1, 1, 0)] + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + rel material:binding = + point3f[] points = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (1, 0), (0, 1)] ( + interpolation = "vertex" + ) + } + + def Mesh "UnitTriangle" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + float3[] extent = [(2, 0, 0), (3, 1, 0)] + int[] faceVertexCounts = [3] + int[] faceVertexIndices = [0, 1, 2] + rel material:binding = + point3f[] points = [(2, 0, 0), (3, 0, 0), (2, 1, 0)] + texCoord2f[] primvars:st = [(0, 0), (1, 0), (0, 1)] ( + interpolation = "vertex" + ) + } + + def "Materials" + { + # base_color * base_weight = (0.041, 0.5, 0.5) * 0.8 = (0.0328, 0.4, 0.4). + # glTF has no separate base weight, so the exporter must fold it into baseColorFactor. + def Material "WeightedMaterial" + { + token outputs:mtlx:surface.connect = + + def NodeGraph "OpenPBR" + { + def Shader "OpenPBR" + { + uniform token info:id = "ND_open_pbr_surface_surfaceshader" + color3f inputs:base_color = (0.041, 0.5, 0.5) + float inputs:base_weight = 0.8 + token outputs:out + } + } + } + + # base_weight = 1.0 must export identically to a material with no base_weight: + # baseColorFactor stays (0.041, 0.5, 0.5). + def Material "UnitWeightMaterial" + { + token outputs:mtlx:surface.connect = + + def NodeGraph "OpenPBR" + { + def Shader "OpenPBR" + { + uniform token info:id = "ND_open_pbr_surface_surfaceshader" + color3f inputs:base_color = (0.041, 0.5, 0.5) + float inputs:base_weight = 1 + token outputs:out + } + } + } + } +} diff --git a/gltf/tests/sanityTests.cpp b/gltf/tests/sanityTests.cpp index 2d67c3b2..d98f08b4 100644 --- a/gltf/tests/sanityTests.cpp +++ b/gltf/tests/sanityTests.cpp @@ -9,21 +9,604 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +#include +#include +#include #include +#include +#include #include #include #include #include #include +#include #include +#include +#include +#include + +#include +#include + +#include PXR_NAMESPACE_USING_DIRECTIVE +// Returns the OpenPBR surface shader prim for a material at the given path, or an invalid prim +// if the material does not exist or was not written as OpenPBR (e.g. legacy mode). +// The OpenPBR writer nests shaders under a NodeGraph named "OpenPBR" inside the material, +// and the surface shader itself is also named "OpenPBR": /OpenPBR/OpenPBR +static UsdPrim +getOpenPbrSurfacePrim(UsdStageRefPtr stage, const std::string& materialPath) +{ + return stage->GetPrimAtPath(SdfPath(materialPath + "/OpenPBR/OpenPBR")); +} + +// Reads the resolved value of an OpenPBR surface shader input. +// Uses UsdShadeShader / UsdShadeInput so that: +// - connections are correctly prioritised over local values (UsdShade semantics), and +// - multi-hop connection chains are fully traversed via GetValueProducingAttribute(). +template +static bool +getShaderInput(const UsdPrim& shaderPrim, const char* name, T* out) +{ + UsdShadeInput input = UsdShadeShader(shaderPrim).GetInput(TfToken(name)); + if (!input) + return false; + UsdShadeAttributeVector valueAttrs = + input.GetValueProducingAttributes(/*shaderOutputsOnly=*/false); + for (const UsdAttribute& attr : valueAttrs) { + if (attr.Get(out)) + return true; + } + return false; +} + TEST(GlTFSanityTests, LoadCube) { - // Load an FBX - UsdStageRefPtr stage = UsdStage::Open("SanityCube.gltf"); + // Load a GLTF + UsdStageRefPtr stage = openAssetStage(assetDir + "SanityCube.gltf"); ASSERT_TRUE(stage); UsdPrim mesh = stage->GetPrimAtPath(SdfPath("/SanityCube/Cube")); ASSERT_TRUE(mesh); } + +// glTF import must author an extent on each mesh so downstream UsdGeomBBoxCache queries are +// O(1) rather than re-deriving bounds from every vertex. The authored extent must equal the +// bounds of the mesh points. +TEST(GlTFSanityTests, ImportAuthorsMeshExtent) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "SanityCube.gltf"); + ASSERT_TRUE(stage); + + // The mesh is nested under the node hierarchy, not at a fixed path, so find it by type. + UsdGeomMesh mesh; + for (const UsdPrim& prim : stage->Traverse()) { + if (prim.IsA()) { + mesh = UsdGeomMesh(prim); + break; + } + } + ASSERT_TRUE(mesh) << "no UsdGeomMesh found in imported glTF"; + + UsdAttribute extentAttr = mesh.GetExtentAttr(); + ASSERT_TRUE(extentAttr.IsAuthored()) << "glTF-imported mesh has no authored extent"; + + VtVec3fArray extent; + ASSERT_TRUE(extentAttr.Get(&extent)); + ASSERT_EQ(extent.size(), 2u); + + VtVec3fArray points; + ASSERT_TRUE(mesh.GetPointsAttr().Get(&points)); + ASSERT_FALSE(points.empty()); + + GfRange3f expected; + for (const GfVec3f& pt : points) { + expected.UnionWith(pt); + } + EXPECT_EQ(extent[0], expected.GetMin()); + EXPECT_EQ(extent[1], expected.GetMax()); +} + +// Thin-walled transmission: KHR_materials_transmission with no volume extension. +// The material is thin-walled by default, so base_color should be used as +// transmission_color directly without needing a coat layer. +TEST(GlTFSanityTests, ImportTransmissionThinWalled) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "TransmissionThinWalled.gltf"); + ASSERT_TRUE(stage); + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/TransmissionThinWalled"))) + << "Root prim missing — scene did not import"; + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/TransmissionThinWalled/Cube"))) + << "Cube mesh prim missing"; + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/TransmissionThinWalled/Materials"))) + << "Materials scope missing"; + + // Material attribute checks require OpenPBR mode. + UsdPrim shader = getOpenPbrSurfacePrim( + stage, "/TransmissionThinWalled/Materials/TransmissionThinWalledMaterial"); + if (!shader) { + GTEST_SKIP() << "OpenPBR surface shader not present — skipping attribute checks " + "(non-OpenPBR mode?)"; + } + + // transmission_weight should equal the glTF transmissionFactor (0.9). + float transmissionWeight = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "transmission_weight", &transmissionWeight)) + << "transmission_weight input missing or unreadable"; + EXPECT_NEAR(transmissionWeight, 0.9f, 1e-5f); + + // transmission_color should equal base_color (0.8, 0.3, 0.1) — the thin-walled direct path. + GfVec3f transmissionColor; + ASSERT_TRUE(getShaderInput(shader, "transmission_color", &transmissionColor)) + << "transmission_color input missing or unreadable"; + EXPECT_EQ(transmissionColor, GfVec3f(0.8f, 0.3f, 0.1f)) + << "transmission_color should equal base_color for thin-walled transmission"; + + // No coat layer should have been created — thin-walled takes the direct path. + float coatWeight = 0.0f; + if (getShaderInput(shader, "coat_weight", &coatWeight)) { + EXPECT_EQ(coatWeight, 0.0f) << "coat_weight should be 0 for thin-walled transmission"; + } +} + +// Volumetric transmission: KHR_materials_transmission + KHR_materials_volume with +// attenuationDistance > 0 and a non-white base color. In OpenPBR mode this exercises +// the copyBaseSurfaceToCoat path — base_color is moved to coat_color so that surface +// tinting is preserved for the volumetric material. +TEST(GlTFSanityTests, ImportTransmissionVolumetric) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "TransmissionVolumetric.gltf"); + ASSERT_TRUE(stage); + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/TransmissionVolumetric"))) + << "Root prim missing — scene did not import"; + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/TransmissionVolumetric/Cube"))) + << "Cube mesh prim missing"; + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/TransmissionVolumetric/Materials"))) + << "Materials scope missing"; + + // Material attribute checks require OpenPBR mode. + UsdPrim shader = getOpenPbrSurfacePrim( + stage, "/TransmissionVolumetric/Materials/TransmissionVolumetricMaterial"); + if (!shader) { + GTEST_SKIP() << "OpenPBR surface shader not present — skipping attribute checks " + "(non-OpenPBR mode?)"; + } + + // copyBaseSurfaceToCoat copies transmission_weight → coat_weight (0.9). + float coatWeight = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "coat_weight", &coatWeight)) + << "coat_weight input missing or unreadable — copyBaseSurfaceToCoat may not have run"; + EXPECT_NEAR(coatWeight, 0.9f, 1e-5f) + << "coat_weight should equal transmissionFactor for volumetric transmission"; + + // copyBaseSurfaceToCoat copies base_color → coat_color (0.8, 0.3, 0.1). + GfVec3f coatColor; + ASSERT_TRUE(getShaderInput(shader, "coat_color", &coatColor)) + << "coat_color input missing or unreadable"; + EXPECT_EQ(coatColor, GfVec3f(0.8f, 0.3f, 0.1f)) + << "coat_color should equal base_color for volumetric transmission"; +} + +// KHR_materials_coat: maps directly to OpenPBR coat_weight, coat_roughness, and coat_ior. +TEST(GlTFSanityTests, ImportExtCoat) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtCoat.gltf"); + ASSERT_TRUE(stage); + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/ExtCoat/Materials"))) << "Materials scope missing"; + + UsdPrim shader = getOpenPbrSurfacePrim(stage, "/ExtCoat/Materials/CoatMaterial"); + if (!shader) { + GTEST_SKIP() << "OpenPBR surface shader not present (non-OpenPBR mode?)"; + } + + float coatWeight = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "coat_weight", &coatWeight)) + << "coat_weight missing or unreadable"; + EXPECT_NEAR(coatWeight, 0.7f, 1e-5f); + + float coatRoughness = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "coat_roughness", &coatRoughness)) + << "coat_roughness missing or unreadable"; + EXPECT_NEAR(coatRoughness, 0.3f, 1e-5f); + + float coatIor = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "coat_ior", &coatIor)) << "coat_ior missing or unreadable"; + EXPECT_NEAR(coatIor, 1.8f, 1e-5f); +} + +// KHR_materials_diffuse_roughness: maps diffuseRoughnessFactor to base_diffuse_roughness. +TEST(GlTFSanityTests, ImportExtDiffuseRoughness) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtDiffuseRoughness.gltf"); + ASSERT_TRUE(stage); + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/ExtDiffuseRoughness/Materials"))) + << "Materials scope missing"; + + UsdPrim shader = + getOpenPbrSurfacePrim(stage, "/ExtDiffuseRoughness/Materials/DiffuseRoughnessMaterial"); + if (!shader) { + GTEST_SKIP() << "OpenPBR surface shader not present (non-OpenPBR mode?)"; + } + + float diffuseRoughness = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "base_diffuse_roughness", &diffuseRoughness)) + << "base_diffuse_roughness missing or unreadable"; + EXPECT_NEAR(diffuseRoughness, 0.4f, 1e-5f); +} + +// KHR_materials_dispersion: maps dispersion to transmission_dispersion_scale and also sets +// transmission_dispersion_abbe_number to the hardcoded value 20.0. +TEST(GlTFSanityTests, ImportExtDispersion) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtDispersion.gltf"); + ASSERT_TRUE(stage); + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/ExtDispersion/Materials"))) + << "Materials scope missing"; + + UsdPrim shader = getOpenPbrSurfacePrim(stage, "/ExtDispersion/Materials/DispersionMaterial"); + if (!shader) { + GTEST_SKIP() << "OpenPBR surface shader not present (non-OpenPBR mode?)"; + } + + float dispersionScale = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "transmission_dispersion_scale", &dispersionScale)) + << "transmission_dispersion_scale missing or unreadable"; + EXPECT_NEAR(dispersionScale, 0.05f, 1e-5f); + + float abbeNumber = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "transmission_dispersion_abbe_number", &abbeNumber)) + << "transmission_dispersion_abbe_number missing or unreadable"; + EXPECT_NEAR(abbeNumber, 20.0f, 1e-5f); +} + +// KHR_materials_fuzz: maps fuzzFactor, fuzzColorFactor, and fuzzRoughnessFactor to the +// OpenPBR fuzz lobe (fuzz_weight, fuzz_color, fuzz_roughness). +TEST(GlTFSanityTests, ImportExtFuzz) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtFuzz.gltf"); + ASSERT_TRUE(stage); + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/ExtFuzz/Materials"))) << "Materials scope missing"; + + UsdPrim shader = getOpenPbrSurfacePrim(stage, "/ExtFuzz/Materials/FuzzMaterial"); + if (!shader) { + GTEST_SKIP() << "OpenPBR surface shader not present (non-OpenPBR mode?)"; + } + + float fuzzWeight = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "fuzz_weight", &fuzzWeight)) + << "fuzz_weight missing or unreadable"; + EXPECT_NEAR(fuzzWeight, 0.8f, 1e-5f); + + GfVec3f fuzzColor; + ASSERT_TRUE(getShaderInput(shader, "fuzz_color", &fuzzColor)) + << "fuzz_color missing or unreadable"; + EXPECT_EQ(fuzzColor, GfVec3f(0.9f, 0.5f, 0.2f)); + + float fuzzRoughness = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "fuzz_roughness", &fuzzRoughness)) + << "fuzz_roughness missing or unreadable"; + EXPECT_NEAR(fuzzRoughness, 0.6f, 1e-5f); +} + +// KHR_materials_iridescence: maps to the OpenPBR thin-film lobe. +// iridescenceThicknessMaximum is converted from nanometers to micrometers (×0.001). +TEST(GlTFSanityTests, ImportExtIridescence) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtIridescence.gltf"); + ASSERT_TRUE(stage); + ASSERT_TRUE(stage->GetPrimAtPath(SdfPath("/ExtIridescence/Materials"))) + << "Materials scope missing"; + + UsdPrim shader = getOpenPbrSurfacePrim(stage, "/ExtIridescence/Materials/IridescenceMaterial"); + if (!shader) { + GTEST_SKIP() << "OpenPBR surface shader not present (non-OpenPBR mode?)"; + } + + float thinFilmWeight = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "thin_film_weight", &thinFilmWeight)) + << "thin_film_weight missing or unreadable"; + EXPECT_NEAR(thinFilmWeight, 0.75f, 1e-5f); + + float thinFilmIor = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "thin_film_ior", &thinFilmIor)) + << "thin_film_ior missing or unreadable"; + EXPECT_NEAR(thinFilmIor, 1.5f, 1e-5f); + + // 500 nm × 0.001 = 0.5 μm + float thinFilmThickness = 0.0f; + ASSERT_TRUE(getShaderInput(shader, "thin_film_thickness", &thinFilmThickness)) + << "thin_film_thickness missing or unreadable"; + EXPECT_NEAR(thinFilmThickness, 0.5f, 1e-5f) + << "thin_film_thickness should be iridescenceThicknessMaximum (500 nm) × 0.001 = 0.5 μm"; +} + +// Helper: read an exported .gltf file back as a string. +static std::string +readExportedGltf(const std::string& path) +{ + std::ifstream f(path); + if (!f.is_open()) + return {}; + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +// Helper: parse an exported .gltf file as JSON. Returns a null JSON value on failure. +static nlohmann::json +parseExportedGltf(const std::string& path) +{ + std::ifstream f(path); + if (!f.is_open()) + return nullptr; + try { + return nlohmann::json::parse(f); + } catch (const nlohmann::json::parse_error&) { + return nullptr; + } +} + +// KHR_materials_coat export — simple coat round-trip. +// ExtCoatSimple.gltf uses coatIor=1.5 and coatDarkeningFactor=0.0, which are both equal to +// the KHR_materials_clearcoat defaults. On round-trip the exporter should therefore emit only +// KHR_materials_clearcoat (the simpler, more widely-supported extension) and NOT +// KHR_materials_coat. +TEST(GlTFSanityTests, ExportCoatSimple) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtCoatSimple.gltf"); + ASSERT_TRUE(stage) << "Failed to open ExtCoatSimple.gltf"; + + std::string outPath = assetDir + "ExportCoatSimple_out.gltf"; + stage->Export(outPath); + + std::string json = readExportedGltf(outPath); + ASSERT_FALSE(json.empty()) << "Exported GLTF file is empty or could not be read: " << outPath; + + // Simple coat should be represented by KHR_materials_clearcoat. + EXPECT_NE(json.find("\"KHR_materials_clearcoat\""), std::string::npos) + << "KHR_materials_clearcoat should be present in exported GLTF for simple coat"; + + // KHR_materials_coat must NOT be present — all coat data fits within clearcoat. + // Note: "KHR_materials_coat" is not a substring of "KHR_materials_clearcoat", + // so this check is unambiguous. + EXPECT_EQ(json.find("\"KHR_materials_coat\""), std::string::npos) + << "KHR_materials_coat should NOT be present when coat data fits within clearcoat"; +} + +// KHR_materials_coat export — advanced coat round-trip. +// ExtCoat.gltf uses coatIor=1.8 (≠ clearcoat default 1.5) and omits coatDarkeningFactor +// (defaults to 1.0 ≠ clearcoat equivalent 0.0). These values cannot be represented by +// KHR_materials_clearcoat, so KHR_materials_coat must be present in the exported output. +TEST(GlTFSanityTests, ExportCoatAdvanced) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtCoat.gltf"); + ASSERT_TRUE(stage) << "Failed to open ExtCoat.gltf"; + + std::string outPath = assetDir + "ExportCoatAdvanced_out.gltf"; + stage->Export(outPath); + + std::string json = readExportedGltf(outPath); + ASSERT_FALSE(json.empty()) << "Exported GLTF file is empty or could not be read: " << outPath; + + EXPECT_NE(json.find("\"KHR_materials_coat\""), std::string::npos) + << "KHR_materials_coat should be present for advanced coat data (coatIor=1.8, " + "coatDarkeningFactor=1.0)"; +} + +// KHR_materials_diffuse_roughness export round-trip. +TEST(GlTFSanityTests, ExportExtDiffuseRoughness) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtDiffuseRoughness.gltf"); + ASSERT_TRUE(stage) << "Failed to open ExtDiffuseRoughness.gltf"; + + std::string outPath = assetDir + "ExportExtDiffuseRoughness_out.gltf"; + stage->Export(outPath); + + std::string json = readExportedGltf(outPath); + ASSERT_FALSE(json.empty()) << "Exported GLTF file is empty or could not be read: " << outPath; + + EXPECT_NE(json.find("\"KHR_materials_diffuse_roughness\""), std::string::npos) + << "KHR_materials_diffuse_roughness should be present in exported GLTF"; +} + +// KHR_materials_dispersion export round-trip. +TEST(GlTFSanityTests, ExportExtDispersion) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtDispersion.gltf"); + ASSERT_TRUE(stage) << "Failed to open ExtDispersion.gltf"; + + std::string outPath = assetDir + "ExportExtDispersion_out.gltf"; + stage->Export(outPath); + + std::string json = readExportedGltf(outPath); + ASSERT_FALSE(json.empty()) << "Exported GLTF file is empty or could not be read: " << outPath; + + EXPECT_NE(json.find("\"KHR_materials_dispersion\""), std::string::npos) + << "KHR_materials_dispersion should be present in exported GLTF"; +} + +// KHR_materials_fuzz export round-trip. +TEST(GlTFSanityTests, ExportExtFuzz) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtFuzz.gltf"); + ASSERT_TRUE(stage) << "Failed to open ExtFuzz.gltf"; + + std::string outPath = assetDir + "ExportExtFuzz_out.gltf"; + stage->Export(outPath); + + std::string json = readExportedGltf(outPath); + ASSERT_FALSE(json.empty()) << "Exported GLTF file is empty or could not be read: " << outPath; + + EXPECT_NE(json.find("\"KHR_materials_fuzz\""), std::string::npos) + << "KHR_materials_fuzz should be present in exported GLTF"; +} + +// KHR_materials_iridescence export round-trip. +TEST(GlTFSanityTests, ExportExtIridescence) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtIridescence.gltf"); + ASSERT_TRUE(stage) << "Failed to open ExtIridescence.gltf"; + + std::string outPath = assetDir + "ExportExtIridescence_out.gltf"; + stage->Export(outPath); + + std::string json = readExportedGltf(outPath); + ASSERT_FALSE(json.empty()) << "Exported GLTF file is empty or could not be read: " << outPath; + + EXPECT_NE(json.find("\"KHR_materials_iridescence\""), std::string::npos) + << "KHR_materials_iridescence should be present in exported GLTF"; +} + +// Helper: look up KHR_materials_volume_scatter from material[0] of a parsed GLTF. +// Returns a null JSON value if the extension is absent. +static nlohmann::json +getVolumeScatterExt(const nlohmann::json& gltf) +{ + try { + return gltf.at("materials").at(0).at("extensions").at("KHR_materials_volume_scatter"); + } catch (const nlohmann::json::out_of_range&) { + return nullptr; + } +} + +// KHR_materials_volume_scatter export round-trip — specular (KHR_materials_transmission) variant. +// +// The source GLTF combines KHR_materials_transmission + KHR_materials_volume + +// KHR_materials_volume_scatter with both a multiscatterColorFactor [0.5, 0.7, 0.9] and a +// multiscatterColorTexture. +// +// On import, translateMultiscatterToSingleScatter multiplies each texel by the factor before +// applying the (nonlinear) conversion, then resets the scale to white. This means the factor +// is fully consumed into the converted texture; transmission_scatter carries no residual scale. +// +// On re-export, translateSingleScatterToMultiscatter converts the texture back and +// addTextureToExt emits the (default-white) scale as multiscatterColorFactor = [1, 1, 1]. +// The texture itself should also survive. +TEST(GlTFSanityTests, ExportExtVolumeScatterTransmission) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtVolumeScatterTransmission.gltf"); + ASSERT_TRUE(stage) << "Failed to open ExtVolumeScatterTransmission.gltf"; + + std::string outPath = assetDir + "ExportExtVolumeScatterTransmission_out.gltf"; + stage->Export(outPath); + + nlohmann::json gltf = parseExportedGltf(outPath); + ASSERT_FALSE(gltf.is_null()) << "Could not parse exported GLTF: " << outPath; + + // The round-trip should produce exactly one image: the re-converted multiscatter texture. + // A second image would indicate the intermediate single-scatter image leaked into the output. + ASSERT_TRUE(gltf.contains("images")) << "No images array in exported GLTF"; + EXPECT_EQ(gltf["images"].size(), 1u) + << "Expected exactly 1 image; a second image suggests the intermediate singlescatter " + "texture was incorrectly exported"; + + nlohmann::json ext = getVolumeScatterExt(gltf); + ASSERT_FALSE(ext.is_null()) << "KHR_materials_volume_scatter not found in exported material"; + + EXPECT_TRUE(ext.contains("multiscatterColorTexture")) + << "multiscatterColorTexture missing — texture did not survive the round-trip"; + + // The source factor [0.5, 0.7, 0.9] was baked into the texture pixels during import. + // The re-exported factor should therefore be white [1, 1, 1] (no residual tint). + ASSERT_TRUE(ext.contains("multiscatterColorFactor")) + << "multiscatterColorFactor missing — expected white [1,1,1] to be emitted alongside the " + "re-exported texture"; + const auto& factor = ext["multiscatterColorFactor"]; + ASSERT_EQ(factor.size(), 3u) << "multiscatterColorFactor should have 3 components"; + EXPECT_NEAR(factor[0].get(), 1.0, 1e-4); + EXPECT_NEAR(factor[1].get(), 1.0, 1e-4); + EXPECT_NEAR(factor[2].get(), 1.0, 1e-4); +} + +// KHR_materials_volume_scatter export round-trip — diffuse (KHR_materials_diffuse_transmission) +// variant. +// +// The source GLTF combines KHR_materials_diffuse_transmission + KHR_materials_volume + +// KHR_materials_volume_scatter with both a multiscatterColorFactor [0.5, 0.7, 0.9] and a +// multiscatterColorTexture. On import the texture is stored directly on subsurface_color +// (no formula conversion needed) and the factor is stored as the texture scale. On re-export +// both should be written back out with the factor value intact. +TEST(GlTFSanityTests, ExportExtVolumeScatterDiffuseTransmission) +{ + UsdStageRefPtr stage = openAssetStage(assetDir + "ExtVolumeScatterDiffuseTransmission.gltf"); + ASSERT_TRUE(stage) << "Failed to open ExtVolumeScatterDiffuseTransmission.gltf"; + + std::string outPath = assetDir + "ExportExtVolumeScatterDiffuseTransmission_out.gltf"; + stage->Export(outPath); + + nlohmann::json gltf = parseExportedGltf(outPath); + ASSERT_FALSE(gltf.is_null()) << "Could not parse exported GLTF: " << outPath; + + nlohmann::json ext = getVolumeScatterExt(gltf); + ASSERT_FALSE(ext.is_null()) << "KHR_materials_volume_scatter not found in exported material"; + + EXPECT_TRUE(ext.contains("multiscatterColorTexture")) + << "multiscatterColorTexture missing — texture did not survive the round-trip"; + + ASSERT_TRUE(ext.contains("multiscatterColorFactor")) + << "multiscatterColorFactor missing — factor was not re-exported alongside the texture"; + const auto& factor = ext["multiscatterColorFactor"]; + ASSERT_EQ(factor.size(), 3u) << "multiscatterColorFactor should have 3 components"; + EXPECT_NEAR(factor[0].get(), 0.5, 1e-4); + EXPECT_NEAR(factor[1].get(), 0.7, 1e-4); + EXPECT_NEAR(factor[2].get(), 0.9, 1e-4); +} + +// Helper: return the baseColorFactor array of the material with the given name, or a null JSON +// value if no such material (or factor) exists. +static nlohmann::json +getBaseColorFactor(const nlohmann::json& gltf, const std::string& materialName) +{ + if (!gltf.contains("materials")) + return nullptr; + for (const auto& material : gltf["materials"]) { + if (material.value("name", std::string()) == materialName) { + try { + return material.at("pbrMetallicRoughness").at("baseColorFactor"); + } catch (const nlohmann::json::out_of_range&) { + return nullptr; + } + } + } + return nullptr; +} + +// OpenPBR base_weight export — the diffuse albedo is base_color * base_weight, but glTF has no +// separate base weight, so the exporter must fold the weight into baseColorFactor. A material +// with base_weight = 1.0 must export unchanged. Only the native OpenPBR export path reads +// base_weight, so this test is meaningless when native processing is disabled. +TEST(GlTFSanityTests, ExportOpenPbrBaseWeight) +{ + if (!adobe::usd::isNativeOpenPbrProcessingEnabled()) { + GTEST_SKIP() << "base_weight is only read on the native OpenPBR export path"; + } + + UsdStageRefPtr stage = openAssetStage(assetDir + "openpbr_base_weight.usda"); + ASSERT_TRUE(stage) << "Failed to open openpbr_base_weight.usda"; + + std::string outPath = assetDir + "ExportOpenPbrBaseWeight_out.gltf"; + stage->Export(outPath); + + nlohmann::json gltf = parseExportedGltf(outPath); + ASSERT_FALSE(gltf.is_null()) << "Could not parse exported GLTF: " << outPath; + + // base_color (0.041, 0.5, 0.5) * base_weight 0.8 = (0.0328, 0.4, 0.4). + nlohmann::json weighted = getBaseColorFactor(gltf, "WeightedMaterial"); + ASSERT_FALSE(weighted.is_null()) << "WeightedMaterial baseColorFactor not found"; + ASSERT_EQ(weighted.size(), 4u) << "baseColorFactor should have 4 components"; + EXPECT_NEAR(weighted[0].get(), 0.0328, 1e-4) + << "base_weight 0.8 was not folded into baseColorFactor"; + EXPECT_NEAR(weighted[1].get(), 0.4, 1e-4); + EXPECT_NEAR(weighted[2].get(), 0.4, 1e-4); + + // base_weight 1.0 must leave base_color untouched. + nlohmann::json unit = getBaseColorFactor(gltf, "UnitWeightMaterial"); + ASSERT_FALSE(unit.is_null()) << "UnitWeightMaterial baseColorFactor not found"; + ASSERT_EQ(unit.size(), 4u) << "baseColorFactor should have 4 components"; + EXPECT_NEAR(unit[0].get(), 0.041, 1e-4) + << "base_weight 1.0 should not change baseColorFactor"; + EXPECT_NEAR(unit[1].get(), 0.5, 1e-4); + EXPECT_NEAR(unit[2].get(), 0.5, 1e-4); +} diff --git a/ipc/CMakeLists.txt b/ipc/CMakeLists.txt new file mode 100644 index 00000000..fdc8dcc7 --- /dev/null +++ b/ipc/CMakeLists.txt @@ -0,0 +1,90 @@ +set(TARGET_NAME usdIpc) +project(${TARGET_NAME}) + +if (NOT TARGET usd) + find_package(pxr REQUIRED) +endif() + +set(IPC_COMMON_SOURCES + "src/pipe.cpp" + "src/factory.cpp" + "src/sharedMemory.cpp" +) + +set(IPC_COMMON_HEADERS + "include/ipc/api.h" + "include/ipc/platformConfig.h" + "include/ipc/pipe.h" + "include/ipc/sharedMemory.h" + "include/ipc/process.h" +) + +if(WIN32) + set(PLATFORM_SOURCES + "src/win/sharedMemoryWin.cpp" + "src/win/processWin.cpp" + "src/win/utilitiesWin.cpp" + ) + set(PLATFORM_HEADERS + "src/win/sharedMemoryWin.h" + "src/win/processWin.h" + "src/win/utilitiesWin.h" + ) +elseif(APPLE OR UNIX) + set(PLATFORM_SOURCES + "src/posix/sharedMemoryPosix.cpp" + "src/posix/processPosix.cpp" + ) + set(PLATFORM_HEADERS + "src/posix/sharedMemoryPosix.h" + "src/posix/processPosix.h" + ) +endif() + +add_library(${TARGET_NAME} SHARED + ${IPC_COMMON_SOURCES} + ${IPC_COMMON_HEADERS} + ${PLATFORM_SOURCES} + ${PLATFORM_HEADERS} +) + +target_compile_definitions(${TARGET_NAME} PRIVATE IPC_EXPORTS) + +# Don't use windows.h min/max, which shadow the C++ versions +if(WIN32) + target_compile_definitions(${TARGET_NAME} PUBLIC NOMINMAX) +endif() + +target_include_directories(${TARGET_NAME} + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" +) + +if(APPLE OR UNIX) + set(CMAKE_MACOSX_RPATH TRUE) +endif() + +set_target_properties(${TARGET_NAME} PROPERTIES + INSTALL_RPATH "${CMAKE_INSTALL_RPATH}" + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + +target_link_libraries(${TARGET_NAME} + PUBLIC + arch + tf +) + +if(UNIX AND NOT APPLE) + # shm_open / shm_unlink live in librt on Linux (cf. old fix #1156). + target_link_libraries(${TARGET_NAME} PUBLIC rt) +endif() + +install(TARGETS ${TARGET_NAME}) + +if(USD_FILEFORMATS_BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/ipc/include/ipc/api.h b/ipc/include/ipc/api.h new file mode 100644 index 00000000..01a3ebee --- /dev/null +++ b/ipc/include/ipc/api.h @@ -0,0 +1,36 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +// This library has no USD dependency besides prints. If needed, these macros can be manually +// written out here and the PXR include removed, if prints are removed as well + +#include "pxr/base/arch/export.h" + +#if defined(PXR_STATIC) +#define IPC_API +#define IPC_API_TEMPLATE_CLASS(...) +#define IPC_API_TEMPLATE_STRUCT(...) +#define IPC_LOCAL +#else +#if defined(IPC_EXPORTS) +#define IPC_API ARCH_EXPORT +#define IPC_API_TEMPLATE_CLASS(...) ARCH_EXPORT_TEMPLATE(class, __VA_ARGS__) +#define IPC_API_TEMPLATE_STRUCT(...) ARCH_EXPORT_TEMPLATE(struct, __VA_ARGS__) +#else +#define IPC_API ARCH_IMPORT +#define IPC_API_TEMPLATE_CLASS(...) ARCH_IMPORT_TEMPLATE(class, __VA_ARGS__) +#define IPC_API_TEMPLATE_STRUCT(...) ARCH_IMPORT_TEMPLATE(struct, __VA_ARGS__) +#endif +#define IPC_LOCAL ARCH_HIDDEN +#endif diff --git a/ipc/include/ipc/pipe.h b/ipc/include/ipc/pipe.h new file mode 100644 index 00000000..105a74f5 --- /dev/null +++ b/ipc/include/ipc/pipe.h @@ -0,0 +1,193 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include + +#include + +#if IPC_IS_WINDOWS +#include +#else +#include +#include +#endif + +namespace adobe::usd::ipc { + +/** + * Platform-independent wrapper for a single end of a pipe (POSIX file descriptor or Windows + * HANDLE). Provides read, write, and close operations. + */ +class IPC_API PipeHandle +{ +public: + /** + * Wrap an existing OS pipe end — a Windows HANDLE or a POSIX file descriptor. Defaults to an + * invalid/closed handle. + */ +#if IPC_IS_WINDOWS + explicit PipeHandle(HANDLE h = INVALID_HANDLE_VALUE) + : _handle(h) + {} +#else + explicit PipeHandle(int fd = -1) + : _fd(fd) + {} +#endif + + // Move-only: a pipe handle owns an OS resource + PipeHandle(const PipeHandle&) = delete; + PipeHandle& operator=(const PipeHandle&) = delete; + + PipeHandle(PipeHandle&& other) noexcept +#if IPC_IS_WINDOWS + : _handle(other._handle) + { + other._handle = INVALID_HANDLE_VALUE; + } +#else + : _fd(other._fd) + { + other._fd = -1; + } +#endif + + PipeHandle& operator=(PipeHandle&& other) noexcept + { + if (this != &other) { + Close(); +#if IPC_IS_WINDOWS + _handle = other._handle; + other._handle = INVALID_HANDLE_VALUE; +#else + _fd = other._fd; + other._fd = -1; +#endif + } + return *this; + } + + /** + * Reconstruct a PipeHandle from the decimal string produced by ToString(). Used to hand an + * inherited pipe end to a child process: the parent serializes the handle/fd into an argv + * entry and the child rebuilds the PipeHandle from it. + * + * @param str The raw OS value as text — a Windows HANDLE or a POSIX fd number. This does not + * dup or validate the handle, so the descriptor must actually be inherited by the + * child for it to be usable. + * @return A PipeHandle wrapping that handle/fd. + */ + static PipeHandle FromString(const std::string& str) + { +#if IPC_IS_WINDOWS + unsigned long long handleValue = std::stoull(str); + return PipeHandle((HANDLE)(uintptr_t)handleValue); +#else + return PipeHandle(std::stoi(str)); +#endif + } + + /** + * Serialize the raw handle/fd to a decimal string, for passing this pipe end to a child + * process on its command line (see FromString). + * + * @return The handle/fd as a decimal string. + */ + std::string ToString() const + { +#if IPC_IS_WINDOWS + return std::to_string((uintptr_t)_handle); +#else + return std::to_string(_fd); +#endif + } + + /** + * Write exactly `size` bytes from `data`. + * + * @param data Source buffer; must hold at least `size` bytes. Passing null with a non-zero + * `size` is a programming error: it is rejected (and warned about), not written. + * @param size Number of bytes to write. + * @return true on success; false on I/O error or a null-with-non-zero-size misuse. + */ + bool Write(const void* data, size_t size) const; + + /** + * Read exactly `size` bytes into `buffer`. + * + * @param buffer Destination buffer; must hold at least `size` bytes. Passing null with a + * non-zero `size` is a programming error: it is rejected (and warned about). + * @param size Number of bytes to read. + * @return true if all `size` bytes were read; false on I/O error, EOF, or a null misuse. + */ + bool Read(void* buffer, size_t size) const; + + /** + * Close the underlying handle/fd if open, and reset it to the invalid value. Safe to call + * more than once. + */ + void Close() + { +#if IPC_IS_WINDOWS + if (_handle != INVALID_HANDLE_VALUE) { + CloseHandle(_handle); + _handle = INVALID_HANDLE_VALUE; + } +#else + if (_fd != -1) { + ::close(_fd); + _fd = -1; + } +#endif + } + + /** + * @return true if this wraps an open handle/fd (i.e. not the default invalid value). + */ + bool IsValid() const + { +#if IPC_IS_WINDOWS + return _handle != INVALID_HANDLE_VALUE; +#else + return _fd != -1; +#endif + } + +private: +#if IPC_IS_WINDOWS + HANDLE _handle = INVALID_HANDLE_VALUE; +#else + int _fd = -1; +#endif +}; + +/// A pair of pipe handles representing the read and write ends of a unidirectional pipe. +struct IPC_API PipePair +{ + PipeHandle readEnd; + PipeHandle writeEnd; +}; + +/** + * Create a unidirectional pipe pair. The pipe handles are created as inheritable so they survive + * fork/exec (POSIX) or CreateProcess (Windows). + * + * @param pair Output parameter that receives the read and write pipe handles. + * @return true if the pipe pair was created successfully, false otherwise. + */ +IPC_API bool +CreatePipePair(PipePair& pair); + +} // namespace adobe::usd::ipc diff --git a/ipc/include/ipc/platformConfig.h b/ipc/include/ipc/platformConfig.h new file mode 100644 index 00000000..ca360a95 --- /dev/null +++ b/ipc/include/ipc/platformConfig.h @@ -0,0 +1,35 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#if defined(_WIN32) || defined(_WIN64) +#define IPC_PLATFORM_NAME "Windows" +#define IPC_IS_WINDOWS 1 +#define IPC_IS_MACOS 0 +#define IPC_IS_LINUX 0 +#elif defined(__APPLE__) && defined(__MACH__) +#define IPC_PLATFORM_NAME "macOS" +#define IPC_IS_WINDOWS 0 +#define IPC_IS_MACOS 1 +#define IPC_IS_LINUX 0 +#elif defined(__linux__) +#define IPC_PLATFORM_NAME "Linux" +#define IPC_IS_WINDOWS 0 +#define IPC_IS_MACOS 0 +#define IPC_IS_LINUX 1 +#else +#define IPC_PLATFORM_NAME "Unknown" +#define IPC_IS_WINDOWS 0 +#define IPC_IS_MACOS 0 +#define IPC_IS_LINUX 0 +#endif diff --git a/ipc/include/ipc/process.h b/ipc/include/ipc/process.h new file mode 100644 index 00000000..2c710dd7 --- /dev/null +++ b/ipc/include/ipc/process.h @@ -0,0 +1,90 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include +#include +#include + +namespace adobe::usd::ipc { + +/** + * Abstract interface for launching and waiting on a child process. Platform-specific + * implementations handle the differences between fork/exec (POSIX) and CreateProcess (Windows). + */ +class IPC_API Process +{ +public: + /** + * Optional callback run in the child process after fork() but before exec() — POSIX only + * (hence the name). This is the window POSIX gives you to harden or reconfigure the child + * before the target binary starts: enter namespaces, install a seccomp filter, drop + * privileges, etc. Return true to proceed to exec(); return false to abort, in which case + * the implementation must terminate the child (_exit) rather than unwind back into the + * parent. + * + * Windows has no fork/exec model — CreateProcess launches the target binary directly — so + * there is no equivalent in-child window, and implementations ignore this callback there. + * Callers needing the same effect on Windows use another mechanism (e.g. a restricted token + * or job object at creation time, or the child reconfiguring itself at the start of main()). + */ + using PosixPreExecHook = std::function; + + virtual ~Process() = default; + + /** + * Launch a child process. + * + * @param commandAndArgs The executable path followed by its arguments. + * commandAndArgs[0] is the executable. + * @param preExecHook Optional POSIX-only hook (see PosixPreExecHook) run in the child after + * fork, before exec. Ignored on Windows. + * @return true if the process was launched successfully. + */ + virtual bool Launch(const std::vector& commandAndArgs, + PosixPreExecHook preExecHook = nullptr) = 0; + + /** + * Block until the child process has exited. + * + * @param exitCode Output parameter set to the child's exit code. + * @return true if the wait succeeded and the exit code was retrieved. + */ + virtual bool Wait(int& exitCode) = 0; + + /** + * Wait up to timeoutMs for the child to exit. + * + * @param timeoutMs Maximum time to wait, in milliseconds. 0 polls once (non-blocking). + * @param exitCode Set to the child's exit code if it exited within the timeout. + * @return true if the child exited within the timeout (exitCode is valid); false if it is + * still running after timeoutMs, on error, or if there is no child to wait for. + */ + virtual bool WaitFor(int timeoutMs, int& exitCode) = 0; + + /** + * Forcibly terminate the child (SIGKILL / TerminateProcess) and reap it. A last resort for a + * worker that will not exit on its own. No-op if no child was launched or it was already + * reaped, so it is safe to call unconditionally during cleanup. + */ + virtual void Terminate() = 0; +}; + +/// Factory function that creates a platform-appropriate Process implementation. +IPC_API std::unique_ptr +CreateSubprocess(); + +} // namespace adobe::usd::ipc diff --git a/ipc/include/ipc/sharedMemory.h b/ipc/include/ipc/sharedMemory.h new file mode 100644 index 00000000..3d96270c --- /dev/null +++ b/ipc/include/ipc/sharedMemory.h @@ -0,0 +1,119 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include +#include + +namespace adobe::usd::ipc { + +/** + * Abstract interface for platform-independent shared memory. Allows two processes to exchange + * binary data through a named shared memory region. + * + * One process creates the shared memory (typically the host), and another connects to it by name + * (typically the sandboxed/child process). + */ +class IPC_API SharedMemory +{ +public: + virtual ~SharedMemory() = default; + + /** + * Create a new shared memory region and map it into this process's address space. + * + * @param name Platform-specific name for the shared memory region. This should not be empty. + * @param size Size in bytes of the shared memory region. + * @return true if the shared memory was created and mapped successfully. + */ + virtual bool Create(const std::string& name, size_t size) = 0; + + /** + * Connect to an existing shared memory region by name and map it into this process's address + * space. + * + * @param name The name of the shared memory region to connect to. + * @param size The expected size of the shared memory region. + * @return true if the connection and mapping were successful. + */ + virtual bool Connect(const std::string& name, size_t size) = 0; + + /** + * Read data from the shared memory region into a buffer. + * + * @param offset Byte offset into the shared memory region. + * @param buffer Destination buffer (must have at least @p size bytes available). + * @param size Number of bytes to read. + * @return true if the read was successful. + */ + virtual bool Read(size_t offset, void* buffer, size_t size); + + /** + * Read data from the shared memory region into an output stream. + * + * @param offset Byte offset into the shared memory region. + * @param stream Destination output stream. + * @param size Number of bytes to read. + * @return true if the read was successful. + */ + virtual bool Read(size_t offset, std::ostream& stream, size_t size); + + /** + * Write data to the shared memory region from a buffer. + * + * @param buffer Source buffer. + * @param size Number of bytes to write. + * @param offset Byte offset into the shared memory region. + * @return true if the write was successful. + */ + virtual bool Write(const void* buffer, size_t size, size_t offset); + + /** + * Get a raw pointer to the mapped shared memory region. Backed by `_buf`, which every + * implementation sets in Create()/Connect() and clears in Clean() — concrete here so that + * member is the single source of truth. Override only if a backend maps memory differently. + * + * @return Pointer to the mapped region, or nullptr before Create()/Connect() (or after + * Clean()). + */ + virtual void* GetBuffer() { return _buf; } + + /** + * Get the size of the shared memory region in bytes. Backed by `_size`, which every + * implementation sets in Create()/Connect() — concrete here so that member is the single + * source of truth. Override only if a backend tracks its size differently. + * + * @return The size of the mapped region in bytes (0 before Create()/Connect()). + */ + virtual size_t GetSize() const { return _size; } + + /** + * Unmap and release the shared memory region. After this call, the shared memory is no longer + * accessible from this process. + */ + virtual void Clean() = 0; + +protected: + std::string _name; + size_t _size = 0; + void* _buf = nullptr; +}; + +/// Factory function that creates a platform-appropriate SharedMemory implementation. +IPC_API std::unique_ptr +CreateSharedMemory(); + +} // namespace adobe::usd::ipc diff --git a/ipc/src/factory.cpp b/ipc/src/factory.cpp new file mode 100644 index 00000000..74f2e5a9 --- /dev/null +++ b/ipc/src/factory.cpp @@ -0,0 +1,51 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include +#include +#include + +#if IPC_IS_WINDOWS +#include "win/processWin.h" +#include "win/sharedMemoryWin.h" +#elif IPC_IS_MACOS || IPC_IS_LINUX +#include "posix/processPosix.h" +#include "posix/sharedMemoryPosix.h" +#endif + +namespace adobe::usd::ipc { + +std::unique_ptr +CreateSharedMemory() +{ +#if IPC_IS_WINDOWS + return std::make_unique(); +#elif IPC_IS_MACOS || IPC_IS_LINUX + return std::make_unique(); +#else + return nullptr; +#endif +} + +std::unique_ptr +CreateSubprocess() +{ +#if IPC_IS_WINDOWS + return std::make_unique(); +#elif IPC_IS_MACOS || IPC_IS_LINUX + return std::make_unique(); +#else + return nullptr; +#endif +} + +} // namespace adobe::usd::ipc diff --git a/ipc/src/pipe.cpp b/ipc/src/pipe.cpp new file mode 100644 index 00000000..cdd75108 --- /dev/null +++ b/ipc/src/pipe.cpp @@ -0,0 +1,118 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#if IPC_IS_WINDOWS +#include +#else +#include +#include +#include +#include +#endif + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::ipc { + +bool +CreatePipePair(PipePair& pair) +{ +#if IPC_IS_WINDOWS + SECURITY_ATTRIBUTES sa = { sizeof(SECURITY_ATTRIBUTES), NULL, TRUE }; + HANDLE readEnd = INVALID_HANDLE_VALUE; + HANDLE writeEnd = INVALID_HANDLE_VALUE; + + if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) { + TF_WARN("IPC: Failed to create pipe pair (Windows error %lu)", GetLastError()); + return false; + } + + pair.readEnd = PipeHandle(readEnd); + pair.writeEnd = PipeHandle(writeEnd); + return true; +#else + int fds[2]; + if (pipe(fds) == -1) { + TF_WARN("IPC: Failed to create pipe pair: %s", strerror(errno)); + return false; + } + // Clear FD_CLOEXEC so file descriptors survive exec() + fcntl(fds[0], F_SETFD, 0); + fcntl(fds[1], F_SETFD, 0); + + pair.readEnd = PipeHandle(fds[0]); + pair.writeEnd = PipeHandle(fds[1]); + return true; +#endif +} + +bool +PipeHandle::Write(const void* data, size_t size) const +{ + if (size > 0 && data == nullptr) { + TF_WARN("IPC: PipeHandle::Write called with null data and non-zero size %zu", size); + return false; + } +#if IPC_IS_WINDOWS + DWORD bytesWritten; + return WriteFile(_handle, data, (DWORD)size, &bytesWritten, NULL) && bytesWritten == size; +#else + const char* ptr = static_cast(data); + size_t remaining = size; + while (remaining > 0) { + ssize_t n = ::write(_fd, ptr, remaining); + if (n == -1) { + if (errno == EINTR) + continue; // signal interrupted, retry + return false; + } + ptr += n; + remaining -= n; + } + return true; +#endif +} + +bool +PipeHandle::Read(void* buffer, size_t size) const +{ + if (size > 0 && buffer == nullptr) { + TF_WARN("IPC: PipeHandle::Read called with null buffer and non-zero size %zu", size); + return false; + } +#if IPC_IS_WINDOWS + DWORD bytesRead; + return ReadFile(_handle, buffer, (DWORD)size, &bytesRead, NULL) && bytesRead == size; +#else + char* ptr = static_cast(buffer); + size_t remaining = size; + while (remaining > 0) { + ssize_t n = ::read(_fd, ptr, remaining); + if (n == 0) + return false; // EOF: write end was closed + if (n == -1) { + if (errno == EINTR) + continue; // signal interrupted, retry + return false; + } + ptr += n; + remaining -= n; + } + return true; +#endif +} + +} // namespace adobe::usd::ipc diff --git a/ipc/src/posix/processPosix.cpp b/ipc/src/posix/processPosix.cpp new file mode 100644 index 00000000..ffa9336d --- /dev/null +++ b/ipc/src/posix/processPosix.cpp @@ -0,0 +1,161 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include "processPosix.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::ipc { + +// Interpret a reaped child's waitpid status. Returns true on clean exit (sets exitCode to the +// exit status); false if the child was signalled or exited abnormally (sets exitCode to -1). +static bool +interpretWaitStatus(int status, int& exitCode) +{ + if (WIFEXITED(status)) { + exitCode = WEXITSTATUS(status); + return true; + } + if (WIFSIGNALED(status)) { + TF_WARN("IPC: Child process terminated by signal %d", WTERMSIG(status)); + } + exitCode = -1; + return false; +} + +bool +ProcessPosix::Launch(const std::vector& commandAndArgs, PosixPreExecHook preExecHook) +{ + if (commandAndArgs.empty()) { + TF_WARN("IPC: Cannot launch process with empty command"); + return false; + } + + // Note: after the process calls fork(), the child process must only use async-signal-safe + // functions until exec() is called. (See https://man7.org/linux/man-pages/man2/fork.2.html + // and https://man7.org/linux/man-pages/man7/signal-safety.7.html). + + // Build the arguments used to exec the child before forking. std::vector allocation is not + // async safe, since it may use already-held locks, deadlocking the child. The c_str() + // pointers stay valid in the child via copy-on-write. + std::vector argv; + argv.reserve(commandAndArgs.size() + 1); + for (const auto& arg : commandAndArgs) { + argv.push_back(const_cast(arg.c_str())); + } + argv.push_back(nullptr); + + _pid = fork(); + if (_pid == -1) { + TF_WARN("IPC: fork() failed: %s", strerror(errno)); + return false; + } + + if (_pid == 0) { + // Child process: between fork() and exec() only async-signal-safe calls are permitted. + // Diagnostics use a bare write(); argv was built above. + if (preExecHook && !preExecHook()) { + const char msg[] = "IPC: pre-exec hook failed in child; not exec'ing\n"; + // "Use" the result (negating it) and then discard it (with void cast) to avoid unused + // result compiler warning + (void)!write(STDERR_FILENO, msg, sizeof(msg) - 1); + _exit(EXIT_FAILURE); + } + + // execv: callers must pass an absolute path, since there can be no PATH search to resolve + // commands. This is because a PATH search (i.e. execvp) can allocate and is not async- + // signal-safe. The command will always be absolute (on Linux, the executable's resolved + // path; on macOS, "/usr/bin/sandbox-exec" from hardeningMac). + execv(argv[0], argv.data()); + + // execv only returns on failure. + const char msg[] = "IPC: exec failed in child; not launching worker\n"; + (void)!write(STDERR_FILENO, msg, sizeof(msg) - 1); + _exit(EXIT_FAILURE); + } + + // Parent process continues + return true; +} + +bool +ProcessPosix::Wait(int& exitCode) +{ + if (_pid <= 0) { + TF_WARN("IPC: No child process to wait for"); + return false; + } + + int status = 0; + if (waitpid(_pid, &status, 0) == -1) { + TF_WARN("IPC: waitpid() failed: %s", strerror(errno)); + return false; + } + + _pid = -1; + return interpretWaitStatus(status, exitCode); +} + +bool +ProcessPosix::WaitFor(int timeoutMs, int& exitCode) +{ + if (_pid <= 0) { + return false; + } + + const int kPollIntervalMs = 10; + for (int64_t elapsed = 0;; elapsed += kPollIntervalMs) { + int status = 0; + int waitResult = waitpid(_pid, &status, WNOHANG); + if (waitResult == _pid) { + _pid = -1; + return interpretWaitStatus(status, exitCode); + } + if (waitResult == -1) { + // EINTR: a signal interrupted the poll; retry. Anything else: the child is gone. + if (errno == EINTR) { + continue; + } + _pid = -1; + return false; + } + if (elapsed >= timeoutMs) { + return false; // still running + } + std::this_thread::sleep_for(std::chrono::milliseconds(kPollIntervalMs)); + } +} + +void +ProcessPosix::Terminate() +{ + if (_pid <= 0) { + return; + } + kill(_pid, SIGKILL); + waitpid(_pid, nullptr, 0); + _pid = -1; +} + +} // namespace adobe::usd::ipc diff --git a/ipc/src/posix/processPosix.h b/ipc/src/posix/processPosix.h new file mode 100644 index 00000000..c2a1f7c3 --- /dev/null +++ b/ipc/src/posix/processPosix.h @@ -0,0 +1,42 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include + +namespace adobe::usd::ipc { + +/** + * POSIX implementation of Process: launches the child with fork() + execvp() and reaps it with + * waitpid(). Internal to the ipc library — construct it through ipc::CreateSubprocess(), which + * hands back a Process pointer; callers never name this type directly. + */ +class ProcessPosix : public Process +{ +public: + ProcessPosix() = default; + ~ProcessPosix() override = default; + + bool Launch(const std::vector& commandAndArgs, + PosixPreExecHook preExecHook = nullptr) override; + bool Wait(int& exitCode) override; + bool WaitFor(int timeoutMs, int& exitCode) override; + void Terminate() override; + +private: + pid_t _pid = -1; +}; + +} // namespace adobe::usd::ipc diff --git a/ipc/src/posix/sharedMemoryPosix.cpp b/ipc/src/posix/sharedMemoryPosix.cpp new file mode 100644 index 00000000..9dbb4102 --- /dev/null +++ b/ipc/src/posix/sharedMemoryPosix.cpp @@ -0,0 +1,135 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include "sharedMemoryPosix.h" + +#include + +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::ipc { + +static constexpr int kInvalidFd = -1; + +SharedMemoryPosix::SharedMemoryPosix() + : _fd(kInvalidFd) + , _isOwner(false) +{} + +SharedMemoryPosix::~SharedMemoryPosix() +{ + if (_buf != nullptr) { + munmap(_buf, _size); + _buf = nullptr; + } + if (_fd != kInvalidFd) { + close(_fd); + _fd = kInvalidFd; + } + if (_isOwner && !_name.empty()) { + shm_unlink(_name.c_str()); + } +} + +bool +SharedMemoryPosix::Create(const std::string& name, size_t size) +{ + if (name == "") { + TF_WARN("IPC: Cannot create shared memory with empty name."); + return false; + } + + _name = name; + _size = size; + _isOwner = true; + + shm_unlink(_name.c_str()); + + _fd = shm_open(_name.c_str(), O_CREAT | O_RDWR, 0600); + if (_fd == kInvalidFd) { + TF_WARN("IPC: Failed to create shared memory '%s': %s", name.c_str(), strerror(errno)); + return false; + } + + if (ftruncate(_fd, _size) == -1) { + TF_WARN("IPC: Failed to set shared memory size to %zu: %s", _size, strerror(errno)); + return false; + } + + // Clear FD_CLOEXEC so the fd is inheritable + fcntl(_fd, F_SETFD, 0); + + _buf = mmap(NULL, _size, PROT_READ | PROT_WRITE, MAP_SHARED, _fd, 0); + if (_buf == MAP_FAILED) { + TF_WARN("IPC: Failed to map shared memory '%s': %s", name.c_str(), strerror(errno)); + _buf = nullptr; + return false; + } + + return true; +} + +bool +SharedMemoryPosix::Connect(const std::string& name, size_t size) +{ + if (name == "") { + TF_WARN("IPC: Cannot connect to shared memory with empty name."); + return false; + } + + _name = name; + _size = size; + // _isOwner defaults to false + + _fd = shm_open(_name.c_str(), O_RDWR, 0600); + if (_fd == kInvalidFd) { + TF_WARN("IPC: Failed to open shared memory '%s': %s", name.c_str(), strerror(errno)); + return false; + } + + _buf = mmap(NULL, _size, PROT_READ | PROT_WRITE, MAP_SHARED, _fd, 0); + if (_buf == MAP_FAILED) { + TF_WARN("IPC: Failed to map shared memory '%s': %s", name.c_str(), strerror(errno)); + _buf = nullptr; + return false; + } + + return true; +} + +void +SharedMemoryPosix::Clean() +{ + if (_buf != nullptr) { + munmap(_buf, _size); + _buf = nullptr; + } + if (_fd != kInvalidFd) { + close(_fd); + _fd = kInvalidFd; + } + if (_isOwner && !_name.empty()) { + shm_unlink(_name.c_str()); + } + _name.clear(); + _isOwner = false; + _size = 0; +} + +} // namespace adobe::usd::ipc diff --git a/ipc/src/posix/sharedMemoryPosix.h b/ipc/src/posix/sharedMemoryPosix.h new file mode 100644 index 00000000..82a168cb --- /dev/null +++ b/ipc/src/posix/sharedMemoryPosix.h @@ -0,0 +1,41 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +namespace adobe::usd::ipc { + +/** + * POSIX implementation of SharedMemory backed by shm_open() + mmap() (the library links librt on + * Linux for shm_open/shm_unlink). The creating process owns the region and unlinks it on Clean(); + * a connecting process maps it without owning it. Internal — construct via + * ipc::CreateSharedMemory(). + */ +class SharedMemoryPosix : public SharedMemory +{ +public: + SharedMemoryPosix(); + ~SharedMemoryPosix() override; + + bool Create(const std::string& name, size_t size) override; + bool Connect(const std::string& name, size_t size) override; + void Clean() override; + +private: + int _fd; + bool _isOwner = false; // true only if this process called Create(); controls whether + // Clean/destructor unlinks the region +}; + +} // namespace adobe::usd::ipc diff --git a/ipc/src/sharedMemory.cpp b/ipc/src/sharedMemory.cpp new file mode 100644 index 00000000..d83474ba --- /dev/null +++ b/ipc/src/sharedMemory.cpp @@ -0,0 +1,86 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::ipc { + +bool +SharedMemory::Read(size_t offset, void* buffer, size_t size) +{ + if (buffer == nullptr) { + TF_WARN("IPC: Destination buffer is null in SharedMemory::Read"); + return false; + } + if (_buf == nullptr) { + TF_WARN("IPC: Shared memory not mapped in SharedMemory::Read"); + return false; + } + // Overflow-safe: offset + size can wrap size_t for hostile/buggy values. + if (offset > _size || size > _size - offset) { + TF_WARN("IPC: Read of %zu bytes at offset %zu exceeds shared memory size %zu", + size, + offset, + _size); + return false; + } + memcpy(buffer, static_cast(_buf) + offset, size); + return true; +} + +bool +SharedMemory::Read(size_t offset, std::ostream& stream, size_t size) +{ + if (_buf == nullptr) { + TF_WARN("IPC: Shared memory not mapped in SharedMemory::Read (stream)"); + return false; + } + if (offset > _size || size > _size - offset) { + TF_WARN("IPC: Read of %zu bytes at offset %zu exceeds shared memory size %zu", + size, + offset, + _size); + return false; + } + stream.write(static_cast(_buf) + offset, size); + return true; +} + +bool +SharedMemory::Write(const void* buffer, size_t size, size_t offset) +{ + if (buffer == nullptr) { + TF_WARN("IPC: Source buffer is null in SharedMemory::Write"); + return false; + } + if (_buf == nullptr) { + TF_WARN("IPC: Shared memory not mapped in SharedMemory::Write"); + return false; + } + if (offset > _size || size > _size - offset) { + TF_WARN("IPC: Write of %zu bytes at offset %zu exceeds shared memory size %zu", + size, + offset, + _size); + return false; + } + memcpy(static_cast(_buf) + offset, buffer, size); + return true; +} + +} // namespace adobe::usd::ipc diff --git a/ipc/src/win/processWin.cpp b/ipc/src/win/processWin.cpp new file mode 100644 index 00000000..c67398bd --- /dev/null +++ b/ipc/src/win/processWin.cpp @@ -0,0 +1,134 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include "processWin.h" + +#include + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::ipc { + +ProcessWin::ProcessWin() +{ + ZeroMemory(&_pi, sizeof(_pi)); +} + +ProcessWin::~ProcessWin() +{ + if (_pi.hProcess) { + CloseHandle(_pi.hProcess); + } + if (_pi.hThread) { + CloseHandle(_pi.hThread); + } +} + +bool +ProcessWin::Launch(const std::vector& commandAndArgs, PosixPreExecHook /*preExecHook*/) +{ + if (commandAndArgs.empty()) { + TF_WARN("IPC: Cannot launch process with empty command"); + return false; + } + + // Build command line string with quoting + std::ostringstream cmdLineStream; + for (size_t i = 0; i < commandAndArgs.size(); ++i) { + if (i > 0) + cmdLineStream << " "; + cmdLineStream << "\"" << commandAndArgs[i] << "\""; + } + std::string cmdLine = cmdLineStream.str(); + + STARTUPINFOA si = {}; + si.cb = sizeof(si); + + ZeroMemory(&_pi, sizeof(_pi)); + + BOOL success = CreateProcess(NULL, + const_cast(cmdLine.c_str()), + NULL, + NULL, + TRUE, // Inherit handles + 0, + NULL, + NULL, + &si, + &_pi); + + if (!success) { + TF_WARN("IPC: CreateProcess failed (error %lu)", GetLastError()); + return false; + } + + return true; +} + +bool +ProcessWin::Wait(int& exitCode) +{ + if (_pi.hProcess == NULL) { + TF_WARN("IPC: No child process to wait for"); + return false; + } + + WaitForSingleObject(_pi.hProcess, INFINITE); + + DWORD dwExitCode = 0; + if (!GetExitCodeProcess(_pi.hProcess, &dwExitCode)) { + TF_WARN("IPC: GetExitCodeProcess failed (error %lu)", GetLastError()); + return false; + } + + exitCode = static_cast(dwExitCode); + return true; +} + +bool +ProcessWin::WaitFor(int timeoutMs, int& exitCode) +{ + if (_pi.hProcess == NULL) { + return false; + } + + DWORD waitResult = WaitForSingleObject(_pi.hProcess, static_cast(timeoutMs)); + if (waitResult == WAIT_TIMEOUT) { + return false; // still running + } + if (waitResult != WAIT_OBJECT_0) { + TF_WARN("IPC: WaitForSingleObject failed (error %lu)", GetLastError()); + return false; + } + + DWORD dwExitCode = 0; + if (!GetExitCodeProcess(_pi.hProcess, &dwExitCode)) { + TF_WARN("IPC: GetExitCodeProcess failed (error %lu)", GetLastError()); + return false; + } + exitCode = static_cast(dwExitCode); + return true; +} + +void +ProcessWin::Terminate() +{ + if (_pi.hProcess == NULL) { + return; + } + TerminateProcess(_pi.hProcess, 1); + WaitForSingleObject(_pi.hProcess, INFINITE); +} + +} // namespace adobe::usd::ipc diff --git a/ipc/src/win/processWin.h b/ipc/src/win/processWin.h new file mode 100644 index 00000000..7de19ecd --- /dev/null +++ b/ipc/src/win/processWin.h @@ -0,0 +1,42 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include + +namespace adobe::usd::ipc { + +/** + * Windows implementation of Process: launches the child with CreateProcess() and waits on its + * handle. Internal to the ipc library — construct it through ipc::CreateSubprocess(). The + * PosixPreExecHook is ignored here, as Windows has no fork/exec window to run it in. + */ +class ProcessWin : public Process +{ +public: + ProcessWin(); + ~ProcessWin() override; + + bool Launch(const std::vector& commandAndArgs, + PosixPreExecHook preExecHook = nullptr) override; + bool Wait(int& exitCode) override; + bool WaitFor(int timeoutMs, int& exitCode) override; + void Terminate() override; + +private: + PROCESS_INFORMATION _pi; +}; + +} // namespace adobe::usd::ipc diff --git a/ipc/src/win/sharedMemoryWin.cpp b/ipc/src/win/sharedMemoryWin.cpp new file mode 100644 index 00000000..d971e7e7 --- /dev/null +++ b/ipc/src/win/sharedMemoryWin.cpp @@ -0,0 +1,141 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include "sharedMemoryWin.h" +#include "utilitiesWin.h" + +#include + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::ipc { + +SharedMemoryWin::SharedMemoryWin() + : _mapFile(nullptr) +{} + +SharedMemoryWin::~SharedMemoryWin() +{ + if (_buf != nullptr) { + UnmapViewOfFile(_buf); + _buf = nullptr; + } + if (_mapFile != nullptr) { + CloseHandle(_mapFile); + _mapFile = nullptr; + } +} + +bool +SharedMemoryWin::Create(const std::string& name, size_t size) +{ + if (name.empty()) { + TF_WARN("IPC: Cannot create shared memory with empty name"); + return false; + } + + _name = name; + _size = size; + + if (_mapFile != nullptr) { + TF_WARN("IPC: Shared memory already created"); + return false; + } + + std::string userSid; + if (!GetCurrentProcessUserSid(userSid)) { + TF_WARN("IPC: Failed to get current process user SID"); + return false; + } + + SECURITY_ATTRIBUTES sa; + if (!CreateSecurityAttributes(BuildLowLevelSDDL(userSid), sa)) { + TF_WARN("IPC: Failed to create security attributes"); + return false; + } + + // CreateFileMapping takes the maximum size as a high/low DWORD pair that together form a + // 64-bit value. Split the full _size across both halves so the mapping spans the entire + // region: the host-side bounds checks (SharedMemory::Read/Write) validate against the full + // 64-bit _size, so the mapping must be at least that large or a >4 GiB asset would read or + // write out of bounds. Casting through uint64_t keeps the >> 32 shift well-defined. 64-bit + // process only (see doc/build/windows.md). + const uint64_t size64 = static_cast(_size); + _mapFile = CreateFileMapping(INVALID_HANDLE_VALUE, + &sa, + PAGE_READWRITE, + static_cast(size64 >> 32), + static_cast(size64 & 0xFFFFFFFFu), + _name.c_str()); + + if (_mapFile == nullptr) { + TF_WARN("IPC: CreateFileMapping failed (error %lu)", GetLastError()); + return false; + } + + _buf = MapViewOfFile(_mapFile, FILE_MAP_WRITE, 0, 0, 0); + if (_buf == nullptr) { + TF_WARN("IPC: MapViewOfFile failed (error %lu)", GetLastError()); + CloseHandle(_mapFile); + _mapFile = nullptr; + return false; + } + + return true; +} + +bool +SharedMemoryWin::Connect(const std::string& name, size_t size) +{ + if (name.empty()) { + TF_WARN("IPC: Cannot connect to shared memory with empty name"); + return false; + } + + _name = name; + _size = size; + + _mapFile = OpenFileMapping(FILE_MAP_WRITE, FALSE, _name.c_str()); + if (_mapFile == nullptr) { + TF_WARN("IPC: OpenFileMapping failed (error %lu)", GetLastError()); + return false; + } + + _buf = MapViewOfFile(_mapFile, FILE_MAP_WRITE, 0, 0, 0); + if (_buf == nullptr) { + TF_WARN("IPC: MapViewOfFile failed on connect (error %lu)", GetLastError()); + CloseHandle(_mapFile); + _mapFile = nullptr; + return false; + } + + return true; +} + +void +SharedMemoryWin::Clean() +{ + if (_buf != nullptr) { + UnmapViewOfFile(_buf); + _buf = nullptr; + } + if (_mapFile != nullptr) { + CloseHandle(_mapFile); + _mapFile = nullptr; + } + _size = 0; + _name.clear(); +} + +} // namespace adobe::usd::ipc diff --git a/ipc/src/win/sharedMemoryWin.h b/ipc/src/win/sharedMemoryWin.h new file mode 100644 index 00000000..37d49d83 --- /dev/null +++ b/ipc/src/win/sharedMemoryWin.h @@ -0,0 +1,40 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include + +namespace adobe::usd::ipc { + +/** + * Windows implementation of SharedMemory backed by CreateFileMapping() + MapViewOfFile(). The + * mapping is created with a low-integrity security descriptor (see utilitiesWin.h) so a + * low-integrity peer process can open it. Internal — construct via ipc::CreateSharedMemory(). + */ +class SharedMemoryWin : public SharedMemory +{ +public: + SharedMemoryWin(); + ~SharedMemoryWin() override; + + bool Create(const std::string& name, size_t size) override; + bool Connect(const std::string& name, size_t size) override; + void Clean() override; + +private: + HANDLE _mapFile; +}; + +} // namespace adobe::usd::ipc diff --git a/ipc/src/win/utilitiesWin.cpp b/ipc/src/win/utilitiesWin.cpp new file mode 100644 index 00000000..0090fb73 --- /dev/null +++ b/ipc/src/win/utilitiesWin.cpp @@ -0,0 +1,88 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include "utilitiesWin.h" + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::ipc { + +std::string +BuildLowLevelSDDL(const std::string& userSid) +{ + return "D:(A;;GRGW;;;" + userSid + ")S:(ML;;NW;;;S-1-16-4096)"; +} + +bool +GetCurrentProcessUserSid(std::string& userSid) +{ + HANDLE tokenHandle = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tokenHandle)) { + TF_WARN("IPC: OpenProcessToken failed (error %lu)", GetLastError()); + return false; + } + + DWORD tokenInfoLength = 0; + GetTokenInformation(tokenHandle, TokenUser, nullptr, 0, &tokenInfoLength); + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + TF_WARN("IPC: GetTokenInformation size query failed (error %lu)", GetLastError()); + CloseHandle(tokenHandle); + return false; + } + + PTOKEN_USER tokenUser = reinterpret_cast(malloc(tokenInfoLength)); + if (!tokenUser) { + TF_WARN("IPC: Memory allocation failed for token user info"); + CloseHandle(tokenHandle); + return false; + } + + if (!GetTokenInformation( + tokenHandle, TokenUser, tokenUser, tokenInfoLength, &tokenInfoLength)) { + TF_WARN("IPC: GetTokenInformation failed (error %lu)", GetLastError()); + free(tokenUser); + CloseHandle(tokenHandle); + return false; + } + + LPSTR sidCString = nullptr; + if (!ConvertSidToStringSidA(tokenUser->User.Sid, &sidCString)) { + TF_WARN("IPC: ConvertSidToStringSid failed (error %lu)", GetLastError()); + free(tokenUser); + CloseHandle(tokenHandle); + return false; + } + + userSid = sidCString; + LocalFree(sidCString); + free(tokenUser); + CloseHandle(tokenHandle); + return true; +} + +bool +CreateSecurityAttributes(const std::string& sddl, SECURITY_ATTRIBUTES& sa) +{ + sa.nLength = sizeof(SECURITY_ATTRIBUTES); + sa.bInheritHandle = FALSE; + + if (!ConvertStringSecurityDescriptorToSecurityDescriptor( + sddl.c_str(), SDDL_REVISION_1, &sa.lpSecurityDescriptor, NULL)) { + TF_WARN("IPC: Failed to create security descriptor from SDDL (error %lu)", GetLastError()); + return false; + } + return true; +} + +} // namespace adobe::usd::ipc diff --git a/ipc/src/win/utilitiesWin.h b/ipc/src/win/utilitiesWin.h new file mode 100644 index 00000000..ddac30e1 --- /dev/null +++ b/ipc/src/win/utilitiesWin.h @@ -0,0 +1,54 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include + +namespace adobe::usd::ipc { + +// Windows-only helpers for building the security descriptor applied to the shared-memory mapping, +// so a low-integrity peer process is permitted to open it. Used by SharedMemoryWin. + +/** + * Constructs a dynamic SDDL string that grants read/write access to a specific user SID + * and enforces a low integrity level (S-1-16-4096). + * + * @param userSid The user SID string (e.g., "S-1-5-21-..."). + * @return The constructed SDDL string. + */ +std::string +BuildLowLevelSDDL(const std::string& userSid); + +/** + * Retrieves the SID string for the current process's user. + * + * @param userSid Output string set to the SID (e.g., "S-1-5-21-..."). + * @return true if the SID was retrieved successfully. + */ +bool +GetCurrentProcessUserSid(std::string& userSid); + +/** + * Initializes a SECURITY_ATTRIBUTES structure from an SDDL string. + * + * @param sddl The SDDL string defining security settings. + * @param sa Output SECURITY_ATTRIBUTES structure. + * @return true if initialization succeeded. + */ +bool +CreateSecurityAttributes(const std::string& sddl, SECURITY_ATTRIBUTES& sa); + +} // namespace adobe::usd::ipc diff --git a/ipc/tests/CMakeLists.txt b/ipc/tests/CMakeLists.txt new file mode 100644 index 00000000..da0a6566 --- /dev/null +++ b/ipc/tests/CMakeLists.txt @@ -0,0 +1,23 @@ +find_package(GTest REQUIRED) +include(GoogleTest) + +set(TARGET_NAME ipcTests) + +add_executable(${TARGET_NAME} + testPipe.cpp + testSharedMemory.cpp + testProcess.cpp +) + +target_link_libraries(${TARGET_NAME} PRIVATE + GTest::gtest + GTest::gtest_main + usdIpc +) + +set_target_properties(${TARGET_NAME} PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + +add_test(NAME ${TARGET_NAME} COMMAND ${TARGET_NAME}) diff --git a/ipc/tests/testPipe.cpp b/ipc/tests/testPipe.cpp new file mode 100644 index 00000000..d4a4eacd --- /dev/null +++ b/ipc/tests/testPipe.cpp @@ -0,0 +1,130 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include +#include +#include + +using namespace adobe::usd::ipc; + +TEST(PipeTests, CreatePipePair) +{ + PipePair pair; + ASSERT_TRUE(CreatePipePair(pair)); + EXPECT_TRUE(pair.readEnd.IsValid()); + EXPECT_TRUE(pair.writeEnd.IsValid()); + pair.readEnd.Close(); + pair.writeEnd.Close(); +} + +TEST(PipeTests, WriteAndRead) +{ + PipePair pair; + ASSERT_TRUE(CreatePipePair(pair)); + + const char* message = "Hello, pipe!"; + size_t len = strlen(message) + 1; + + ASSERT_TRUE(pair.writeEnd.Write(message, len)); + pair.writeEnd.Close(); + + char buffer[64] = {}; + ASSERT_TRUE(pair.readEnd.Read(buffer, len)); + EXPECT_STREQ(buffer, message); + + pair.readEnd.Close(); +} + +TEST(PipeTests, WriteAndReadUint32) +{ + PipePair pair; + ASSERT_TRUE(CreatePipePair(pair)); + + uint32_t value = 42; + ASSERT_TRUE(pair.writeEnd.Write(&value, sizeof(value))); + + uint32_t result = 0; + ASSERT_TRUE(pair.readEnd.Read(&result, sizeof(result))); + EXPECT_EQ(result, value); + + pair.readEnd.Close(); + pair.writeEnd.Close(); +} + +TEST(PipeTests, WriteAndReadLargeData) +{ + PipePair pair; + ASSERT_TRUE(CreatePipePair(pair)); + + const size_t dataSize = 65536; + std::vector sendData(dataSize); + for (size_t i = 0; i < dataSize; ++i) { + sendData[i] = static_cast(i & 0xFF); + } + + // Write in a separate thread to avoid blocking on large data + std::thread writer([&]() { + pair.writeEnd.Write(sendData.data(), dataSize); + pair.writeEnd.Close(); + }); + + std::vector recvData(dataSize); + ASSERT_TRUE(pair.readEnd.Read(recvData.data(), dataSize)); + EXPECT_EQ(sendData, recvData); + + writer.join(); + pair.readEnd.Close(); +} + +TEST(PipeTests, HandleToStringAndFromString) +{ + PipePair pair; + ASSERT_TRUE(CreatePipePair(pair)); + + std::string readStr = pair.readEnd.ToString(); + std::string writeStr = pair.writeEnd.ToString(); + + EXPECT_FALSE(readStr.empty()); + EXPECT_FALSE(writeStr.empty()); + EXPECT_NE(readStr, writeStr); + + // Round-trip: reconstruct from string and verify the handles work + PipeHandle reconstructedWrite = PipeHandle::FromString(writeStr); + const char* testMsg = "test"; + ASSERT_TRUE(reconstructedWrite.Write(testMsg, 5)); + + char buffer[5] = {}; + ASSERT_TRUE(pair.readEnd.Read(buffer, 5)); + EXPECT_STREQ(buffer, "test"); + + pair.readEnd.Close(); + pair.writeEnd.Close(); +} + +TEST(PipeTests, InvalidHandle) +{ + PipeHandle invalid; + EXPECT_FALSE(invalid.IsValid()); +} + +TEST(PipeTests, CloseIdempotent) +{ + PipePair pair; + ASSERT_TRUE(CreatePipePair(pair)); + pair.readEnd.Close(); + pair.readEnd.Close(); // second close should not crash + pair.writeEnd.Close(); +} diff --git a/ipc/tests/testProcess.cpp b/ipc/tests/testProcess.cpp new file mode 100644 index 00000000..9c11e58a --- /dev/null +++ b/ipc/tests/testProcess.cpp @@ -0,0 +1,115 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include +#include + +#include + +#include + +using namespace adobe::usd::ipc; + +namespace { +#if IPC_IS_WINDOWS +std::vector +quickExitCommand() +{ + // A child that exits 0: `ping -n 1` to loopback sends one echo, succeeds, and exits 0. + return { "ping", "-n", "1", "127.0.0.1" }; +} +std::vector +longSleepCommand() +{ + // Windows equivalent of the POSIX `sleep`: Windows has no plain sleep command, and `timeout` + // needs a console (it exits immediately under CI's redirected stdin), so `ping` to loopback + // provides the delay (-n 31 ≈ 30s). + return { "ping", "-n", "31", "127.0.0.1" }; +} +#else +std::vector +quickExitCommand() +{ + return { "/bin/sh", "-c", "exit 0" }; +} +std::vector +longSleepCommand() +{ + return { "/bin/sleep", "30" }; +} +#endif +} // namespace + +TEST(ProcessTests, WaitReturnsExitCode) +{ + auto proc = CreateSubprocess(); + ASSERT_NE(proc, nullptr); + ASSERT_TRUE(proc->Launch(quickExitCommand())); + int exitCode = -1; + EXPECT_TRUE(proc->Wait(exitCode)); + EXPECT_EQ(exitCode, 0); +} + +#if !IPC_IS_WINDOWS +// The pre-exec hook is POSIX-only (runs in the child between fork and exec). A hook that returns +// false must abort the child before exec, so the target command never runs: Launch still succeeds +// (fork worked), but the child exits non-zero instead of running quickExitCommand()'s `exit 0`. +TEST(ProcessTests, PreExecHookFailureAbortsChildBeforeExec) +{ + auto proc = CreateSubprocess(); + ASSERT_NE(proc, nullptr); + + auto refusingHook = []() -> bool { return false; }; + ASSERT_TRUE(proc->Launch(quickExitCommand(), refusingHook)); + + int exitCode = 0; + EXPECT_TRUE(proc->Wait(exitCode)); // child was reaped + EXPECT_NE(exitCode, 0); // hook refusal aborted the child; the command's 0 never ran +} +#endif + +TEST(ProcessTests, WaitForReturnsTrueWhenChildExits) +{ + auto proc = CreateSubprocess(); + ASSERT_NE(proc, nullptr); + ASSERT_TRUE(proc->Launch(quickExitCommand())); + int exitCode = -1; + EXPECT_TRUE(proc->WaitFor(5000, exitCode)); // generous; child exits immediately + EXPECT_EQ(exitCode, 0); +} + +TEST(ProcessTests, TerminateLongRunningChild) +{ + auto proc = CreateSubprocess(); + ASSERT_NE(proc, nullptr); + ASSERT_TRUE(proc->Launch(longSleepCommand())); + + int exitCode = -1; + auto start = std::chrono::steady_clock::now(); + ASSERT_FALSE(proc->WaitFor(100, exitCode)); // still running after 100ms + + // Terminate must return promptly (not hang for the full 30s sleep). + proc->Terminate(); + auto elapsedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + EXPECT_LT(elapsedMs, 10000) << "Terminate did not reap the worker promptly"; +} + +TEST(ProcessTests, LifecycleCallsAreSafeWithNoChild) +{ + auto proc = CreateSubprocess(); + ASSERT_NE(proc, nullptr); + int exitCode = -1; + EXPECT_FALSE(proc->WaitFor(0, exitCode)); // no child launched + proc->Terminate(); // no-op, must not crash +} diff --git a/ipc/tests/testSharedMemory.cpp b/ipc/tests/testSharedMemory.cpp new file mode 100644 index 00000000..9f2eeeee --- /dev/null +++ b/ipc/tests/testSharedMemory.cpp @@ -0,0 +1,149 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include +#include + +#include + +#include +#include +#include +#include + +using namespace adobe::usd::ipc; + +namespace { +// Build a platform-appropriate shared-memory name from a base label. POSIX (macOS + Linux) uses a +// leading '/' for shm_open; Windows uses a "Local\" named-object prefix. +std::string +shmName(const std::string& base) +{ +#if IPC_IS_WINDOWS + return "Local\\" + base; +#else + return "/" + base; +#endif +} +} // namespace + +TEST(SharedMemoryTests, CreateAndWrite) +{ + auto shm = CreateSharedMemory(); + ASSERT_TRUE(shm != nullptr); + + std::string name = shmName("TestSHM_CreateAndWrite"); + + ASSERT_TRUE(shm->Create(name, 1024)); + EXPECT_EQ(shm->GetSize(), 1024u); + EXPECT_NE(shm->GetBuffer(), nullptr); + + const char* data = "Hello, shared memory!"; + size_t len = strlen(data) + 1; + ASSERT_TRUE(shm->Write(data, len, 0)); + + char buffer[64] = {}; + ASSERT_TRUE(shm->Read(0, buffer, len)); + EXPECT_STREQ(buffer, data); + + shm->Clean(); +} + +TEST(SharedMemoryTests, ReadToStream) +{ + auto shm = CreateSharedMemory(); + + std::string name = shmName("TestSHM_ReadToStream"); + + ASSERT_TRUE(shm->Create(name, 256)); + + const char* message = "stream test"; + size_t len = strlen(message); + ASSERT_TRUE(shm->Write(message, len, 0)); + + std::ostringstream oss; + ASSERT_TRUE(shm->Read(0, oss, len)); + EXPECT_EQ(oss.str(), "stream test"); + + shm->Clean(); +} + +TEST(SharedMemoryTests, WriteAtOffset) +{ + auto shm = CreateSharedMemory(); + + std::string name = shmName("TestSHM_WriteAtOffset"); + + ASSERT_TRUE(shm->Create(name, 256)); + + const char* part1 = "AAAA"; + const char* part2 = "BBBB"; + ASSERT_TRUE(shm->Write(part1, 4, 0)); + ASSERT_TRUE(shm->Write(part2, 4, 4)); + + char buffer[8] = {}; + ASSERT_TRUE(shm->Read(0, buffer, 8)); + EXPECT_EQ(memcmp(buffer, "AAAABBBB", 8), 0); + + shm->Clean(); +} + +#if IPC_IS_WINDOWS +// A size above 4 GiB must map a region of the full 64-bit size. Writing and reading a payload at +// the 4 GiB boundary only succeeds when CreateFileMapping's high DWORD is honored, so this +// exercises the high/low split directly. +TEST(SharedMemoryTests, CreateMapsFullSizeAboveFourGiB) +{ + auto shm = CreateSharedMemory(); + ASSERT_TRUE(shm != nullptr); + + std::string name = shmName("TestSHM_LargeMapping"); + + // 4 GiB + one page, so the mapping size needs a nonzero high DWORD. Accessing at the 4 GiB + // boundary then confirms the full size was mapped. + const uint64_t fourGiB = static_cast(1) << 32; + const size_t size = static_cast(fourGiB) + 4096; + + ASSERT_TRUE(shm->Create(name, size)); + EXPECT_EQ(shm->GetSize(), size); + + const char payload[] = "past-4GiB"; + const size_t len = sizeof(payload); + ASSERT_TRUE(shm->Write(payload, len, static_cast(fourGiB))); + + char buffer[sizeof(payload)] = {}; + ASSERT_TRUE(shm->Read(static_cast(fourGiB), buffer, len)); + EXPECT_STREQ(buffer, payload); + + shm->Clean(); +} +#endif + +TEST(SharedMemoryTests, BoundsCheck) +{ + auto shm = CreateSharedMemory(); + + std::string name = shmName("TestSHM_BoundsCheck"); + + ASSERT_TRUE(shm->Create(name, 16)); + + char buffer[32] = {}; + // Reading past end should fail + EXPECT_FALSE(shm->Read(0, buffer, 32)); + // Writing past end should fail + EXPECT_FALSE(shm->Write(buffer, 32, 0)); + // Offset + size overflow + EXPECT_FALSE(shm->Read(10, buffer, 10)); + EXPECT_FALSE(shm->Write(buffer, 10, 10)); + + shm->Clean(); +} diff --git a/obj/CMakeLists.txt b/obj/CMakeLists.txt index 38a65f96..9a0e1fee 100644 --- a/obj/CMakeLists.txt +++ b/obj/CMakeLists.txt @@ -1,6 +1,6 @@ option(NO_UNDEFINED "Active no-undefined compile options" ON) option(USD_FILEFORMATS_ENABLE_ASSET_TESTS "Build the more in depth unit tests using downloaded assets." OFF) -option(USDOBJ_ENABLE_INSTALL "Enable installation of plugin artifacts" ON) +cmake_dependent_option(USDOBJ_ENABLE_INSTALL "Enable installation of plugin artifacts" ON "USD_FILEFORMATS_ENABLE_INSTALL" OFF) @@ -16,6 +16,10 @@ find_package(FastFloat REQUIRED) add_subdirectory(src) + +# Pass this list from the src/CMakeLists.txt to the parent scope +set(OBJ_EXT_LIST ${OBJ_EXT_LIST} PARENT_SCOPE) + if(USD_FILEFORMATS_BUILD_TESTS ) add_subdirectory(tests) endif() @@ -24,3 +28,5 @@ endif() set(CPACK_INSTALL_CMAKE_PROJECTS "src;usdObj;ALL;/") include(CPack) + +fileformats_register_plugin("usdObj") diff --git a/obj/README.md b/obj/README.md index 96e3b4bd..d6bee0ae 100644 --- a/obj/README.md +++ b/obj/README.md @@ -104,17 +104,17 @@ Also, the resulting meshes are unitless (obj does not support units). No adjustm * `objAssetsPath`: Deprecated in favor of `assetsPath`. -* `writeUsdPreviewSurface`: Generate a UsdPreviewSurface based network for each material. Default is `true` +* `writeUsdPreviewSurface`: Generate a UsdPreviewSurface based network for each material. Default is `true` (deprecated) UsdPreviewSurface and its associated nodes are a universally understood USD material description and all application should support them. The PBR capabilities are limited. -* `writeASM`: Generate a ASM (Adobe Standard Material) based network for each material. Default is `true` +* `writeASM`: Generate a ASM (Adobe Standard Material) based network for each material. Default is `false` (deprecated) ASM is a standard supported by many Adobe applications with richer support for PBR capabilities. It will be superseded by OpenPBR in the near future. -* `writeOpenPBR`: Generate a OpenPBR based material network for each material. Default is `false` +* `writeOpenPBR`: Generate a OpenPBR based material network for each material. Default is `true` OpenPBR is a new industry standard that will have wide spread support, but is still in its infancy. The material network uses `MaterialX` nodes to express individual operations and has an `OpenPBR` surface, diff --git a/obj/src/CMakeLists.txt b/obj/src/CMakeLists.txt index 43dce85c..e96ebe41 100644 --- a/obj/src/CMakeLists.txt +++ b/obj/src/CMakeLists.txt @@ -1,5 +1,7 @@ add_library(usdObj SHARED) +set(OBJ_EXT_LIST "obj;OBJ;Obj" PARENT_SCOPE) + usd_plugin_compile_config(usdObj) target_compile_definitions(usdObj PRIVATE USDOBJ_EXPORTS) @@ -49,29 +51,39 @@ target_include_directories(usdObj # Allow an option for deferring the path replacement to install time if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) - set(PLUG_INFO_LIBRARY_PATH "\$\{PLUG_INFO_LIBRARY_PATH\}") + # We still need to go through `configure_file` even with `USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT` because we burn additional CMake variable beyond PLUG_INFO_LIBRARY_PATH + # So we set `PLUG_INFO_LIBRARY_PATH` as a no op value and let other CMake variables being burnt in + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") else() set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}usdObj${CMAKE_SHARED_LIBRARY_SUFFIX}") endif() -configure_file(plugInfo.json.in plugInfo.json) -set_target_properties(usdObj PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) -set_target_properties(usdObj PROPERTIES RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") +configure_file(plugInfo.json.in plugInfo.json) +set_property(TARGET usdObj APPEND PROPERTY RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json") +set_property(TARGET usdObj APPEND PROPERTY RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") # USDOBJ_DESTINATION is set in the parent scope by the add_usd_fileformat macro if(USDOBJ_ENABLE_INSTALL) + set_property(TARGET usdObj + APPEND PROPERTY + INSTALL_RPATH "${plugin_install_rpath_root}/." + ) install( TARGETS usdObj + EXPORT usd-fileformats-targets RUNTIME DESTINATION ${USDOBJ_DESTINATION} COMPONENT Runtime LIBRARY DESTINATION ${USDOBJ_DESTINATION} COMPONENT Runtime + ARCHIVE DESTINATION ${USDOBJ_DESTINATION} COMPONENT Runtime RESOURCE DESTINATION ${USDOBJ_DESTINATION}/usdObj/resources COMPONENT Runtime ) - install( - FILES plugInfo.root.json - DESTINATION ${USDOBJ_DESTINATION} - RENAME plugInfo.json - COMPONENT Runtime - ) + if(USD_FILEFORMATS_ENABLE_INSTALL_PLUGINFO_ROOT) + install( + FILES plugInfo.root.json + DESTINATION ${USDOBJ_DESTINATION} + RENAME plugInfo.json + COMPONENT Runtime + ) + endif() endif() diff --git a/obj/src/obj.cpp b/obj/src/obj.cpp index 225e638d..b923bdcc 100644 --- a/obj/src/obj.cpp +++ b/obj/src/obj.cpp @@ -36,7 +36,6 @@ governing permissions and limitations under the License. #include "obj.h" #include "debugCodes.h" #include -#include #include #include #include @@ -44,8 +43,6 @@ governing permissions and limitations under the License. #include #include #include -#include -#include #include #include #include @@ -74,8 +71,6 @@ namespace adobe::usd { /// OBJ READ ////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////// -static const int ZERO_INDEX = std::numeric_limits::max(); - // Helper enum for obj multithreaded parsing, encoding the type of element in the obj data. enum EntryType { @@ -120,6 +115,9 @@ struct ObjIntermediate const char* end = nullptr; bool error = false; std::string errorMsg; + // Non-fatal warnings recorded during parallel parsing; emitted on the main + // thread once parsing has joined (see readObjInternal). + std::vector warnings; VtVec3fArray vertices; VtVec3fArray colors; VtVec2fArray uvs; @@ -137,12 +135,41 @@ struct ObjIntermediate int lineNum; }; +// Escapes non-printable and non-ASCII bytes from attacker-controlled OBJ file +// content before it flows into diagnostic strings. Without this, raw bytes from +// malformed input ride %s into TF_WARN/TF_RUNTIME_ERROR stderr output, and any +// downstream consumer that assumes UTF-8 stderr (e.g. the PSIRT test harness's +// subprocess.stderr.decode) raises UnicodeDecodeError on the first byte >= 0x80. +static std::string +escapeDiagnosticBytes(const std::string& s) +{ + std::string out; + out.reserve(s.size()); + for (unsigned char c : s) { + if (c == '\t' || (c >= 0x20 && c < 0x7F)) { + out.push_back(static_cast(c)); + } else { + char buf[5]; + std::snprintf(buf, sizeof(buf), "\\x%02X", c); + out.append(buf); + } + } + return out; +} + +// Records a parse-error message into the intermediate. Safe to call from worker +// threads — does not invoke TF_WARN/TfNotice machinery, which on Linux can race +// with TBB worker shutdown and static-destructor ordering during sandbox +// teardown. Emission of the recorded messages happens on the main thread once +// the parallel parse has joined. void -warnFromIntermediateAndCalculateLine(const ObjIntermediate& inter, const char* p) +setErrorOnIntermediate(ObjIntermediate& inter, const char* p) { + inter.error = true; + // If the data is empty, can't calculate the line if (inter.dataSize == 0) { - TF_WARN("Error parsing OBJ: error calculating line number of empty data"); + inter.errorMsg = "Error parsing OBJ: error calculating line number of empty data"; return; } @@ -150,7 +177,7 @@ warnFromIntermediateAndCalculateLine(const ObjIntermediate& inter, const char* p // Ensure p points to within the data block if (p >= dataEnd || p < inter.data) { - TF_WARN("Error parsing OBJ: error calculating line number of invalid character"); + inter.errorMsg = "Error parsing OBJ: error calculating line number of invalid character"; return; } @@ -198,7 +225,9 @@ warnFromIntermediateAndCalculateLine(const ObjIntermediate& inter, const char* p size_t lineSize = it - lineBegin; std::string line(lineBegin, lineSize); - TF_WARN("Error parsing OBJ: Failed parsing line %zu:\n%s", lineNum, line.c_str()); + inter.errorMsg = TfStringPrintf("Error parsing OBJ: Failed parsing line %zu:\n%s", + lineNum, + escapeDiagnosticBytes(line).c_str()); } /// Read an entire file to a buffer. @@ -212,7 +241,7 @@ readFileContents(const std::string& filename, std::vector& buffer) fseek64(file, 0, SEEK_END); long long length = ftell64(file); if (length < 0) { - TF_WARN("Unable to read file %s"); + TF_WARN("Unable to read file %s", filename.c_str()); return false; } else { fseek64(file, 0, SEEK_SET); @@ -228,7 +257,7 @@ readFileContents(const std::string& filename, std::vector& buffer) break; } if (ferror(file)) { - TF_WARN("failed to read file"); + TF_WARN("failed to read file %s", filename.c_str()); break; } total_read += current_read; @@ -543,9 +572,7 @@ readObjIntermediate(ObjIntermediate& inter) // inter.begin - inter.data, inter.end - inter.data, inter.data, inter.begin, inter.end); inter.entries.push_back({ EntryTypeNull, 0 }); bool endOfLine; - int lineCount = 0; int vCount = 0; - int vcCount = 0; int vtCount = 0; int vnCount = 0; const char* end = inter.end; // End of the obj string buffer. @@ -565,15 +592,13 @@ readObjIntermediate(ObjIntermediate& inter) bool s5 = nextFloat(p, end, f5); if (s0 && s1 && s2 && s3 && s4 && s5) { vCount++; - vcCount++; inter.vertices.push_back(GfVec3f(f0, f1, f2)); inter.colors.push_back(GfVec3f(f3, f4, f5)); } else if (s0 && s1 && s2) { vCount++; inter.vertices.push_back(GfVec3f(f0, f1, f2)); } else { - inter.error = true; - warnFromIntermediateAndCalculateLine(inter, p); + setErrorOnIntermediate(inter, p); return; } } else if (c0 == 'v' && c1 == 't') { @@ -582,8 +607,7 @@ readObjIntermediate(ObjIntermediate& inter) vtCount++; inter.uvs.push_back(GfVec2f(f0, f1)); } else { - inter.error = true; - warnFromIntermediateAndCalculateLine(inter, p); + setErrorOnIntermediate(inter, p); return; } } else if (c0 == 'v' && c1 == 'n') { @@ -592,8 +616,7 @@ readObjIntermediate(ObjIntermediate& inter) vnCount++; inter.normals.push_back(GfVec3f(f0, f1, f2)); } else { - inter.error = true; - warnFromIntermediateAndCalculateLine(inter, p); + setErrorOnIntermediate(inter, p); return; } } else if (c0 == 'f' && c1 == ' ') { @@ -613,8 +636,7 @@ readObjIntermediate(ObjIntermediate& inter) if (vIndex) { inter.points.push_back(GfVec3i(vIndex, vtIndex, vnIndex)); } else { // can't have all of them fail or being zero - inter.error = true; - warnFromIntermediateAndCalculateLine(inter, p); + setErrorOnIntermediate(inter, p); return; } @@ -626,8 +648,7 @@ readObjIntermediate(ObjIntermediate& inter) addEntry(inter, EntryTypeF, vCount, vtCount, vnCount); } else if (c0 == 'u' && c1 == 's') { if (!checkWord(p, end, "usemtl")) { - inter.error = true; - warnFromIntermediateAndCalculateLine(inter, p); + setErrorOnIntermediate(inter, p); return; } inter.usemtls.push_back(std::string()); @@ -635,8 +656,7 @@ readObjIntermediate(ObjIntermediate& inter) addEntry(inter, EntryTypeUsemtl); } else if (c0 == 'm' && c1 == 't') { if (!checkWord(p, end, "mtllib")) { - inter.error = true; - warnFromIntermediateAndCalculateLine(inter, p); + setErrorOnIntermediate(inter, p); return; } std::string temp; @@ -645,8 +665,7 @@ readObjIntermediate(ObjIntermediate& inter) addEntry(inter, EntryTypeMtllib); } else if (c0 == 'a' && c1 == 'd') { if (!checkWord(p, end, "adobe_mdllib")) { - inter.error = true; - warnFromIntermediateAndCalculateLine(inter, p); + setErrorOnIntermediate(inter, p); return; } std::string temp; @@ -667,31 +686,37 @@ readObjIntermediate(ObjIntermediate& inter) } else if (c0 == '#' && c1 == 'M') { // ZBrush vertex colors block size_t lineLen = countLineLen(p, end); - if (checkWord(p, end, "#MRGB ") && (lineLen - 7) % 8 == 0) { - - // after the 6 char long header, the rest of the row should - // be made of up to 64 hex colors values packed as - // MMRRGGBBMMRRGGBBMMRRGGBB... - size_t colorlen = (lineLen - 7) / 8; - inter.colors.reserve(colorlen); - for (size_t i = 0; i < colorlen; ++i) { - char rs[3], gs[3], bs[3]; - p++; // skip MM - p++; - rs[0] = (*p++); - rs[1] = (*p++); - rs[2] = 0; - gs[0] = (*p++); - gs[1] = (*p++); - gs[2] = 0; - bs[0] = (*p++); - bs[1] = (*p++); - bs[2] = 0; - GfVec3f color; - color[0] = (float)strtol(rs, (char**)nullptr, 16) / 255.f; - color[1] = (float)strtol(gs, (char**)nullptr, 16) / 255.f; - color[2] = (float)strtol(bs, (char**)nullptr, 16) / 255.f; - inter.colors.emplace_back(color); + if (checkWord(p, end, "#mrgb ")) { + // Strip "#mrgb " prefix (6 chars) then strip trailing \r for CRLF files. + // checkWord lowercases the input before comparing, so the word must be lowercase. + size_t hexLen = lineLen - 6; + if (hexLen > 0 && hexLen % 8 != 0 && p[hexLen - 1] == '\r') + hexLen--; + if (hexLen > 0 && hexLen % 8 == 0) { + // after the 6 char long header, the rest of the row should + // be made of up to 64 hex colors values packed as + // MMRRGGBBMMRRGGBBMMRRGGBB... + size_t colorlen = hexLen / 8; + inter.colors.reserve(inter.colors.size() + colorlen); + for (size_t i = 0; i < colorlen; ++i) { + char rs[3], gs[3], bs[3]; + p++; // skip MM + p++; + rs[0] = (*p++); + rs[1] = (*p++); + rs[2] = 0; + gs[0] = (*p++); + gs[1] = (*p++); + gs[2] = 0; + bs[0] = (*p++); + bs[1] = (*p++); + bs[2] = 0; + GfVec3f color; + color[0] = (float)strtol(rs, (char**)nullptr, 16) / 255.f; + color[1] = (float)strtol(gs, (char**)nullptr, 16) / 255.f; + color[2] = (float)strtol(bs, (char**)nullptr, 16) / 255.f; + inter.colors.emplace_back(color); + } } } } else if (c0 == '#' && c1 == ' ') { @@ -701,13 +726,13 @@ readObjIntermediate(ObjIntermediate& inter) } else if (c0 == 'v' && c1 >= '0' && c1 <= '9') { // Detect malformed vertex lines like "v56 ..." instead of "v 56 ..." // This is corrupted data - missing space after 'v' command - TF_WARN("Malformed vertex line at offset %td: line starts with 'v%c' instead of 'v ' - " - "vertex will be skipped. This may cause face index errors.", - p - inter.data, - c1); + inter.warnings.push_back(TfStringPrintf( + "Malformed vertex line at offset %td: line starts with 'v%c' instead of 'v ' - " + "vertex will be skipped. This may cause face index errors.", + p - inter.data, + c1)); } else { } - lineCount++; nextLine(p, end); } } @@ -1156,11 +1181,27 @@ readObjInternal(Obj& obj, w.Start(); WorkParallelForEach(intermediates.begin(), intermediates.end(), readObjIntermediate); + // Emit any worker-recorded warnings and parse errors on the main thread. + // TF_WARN walks the TfNotice registry; doing that from a TBB worker can + // race with static destructor / worker-shutdown ordering at sandbox exit + // on Linux and produce a pure-virtual SIGABRT. Fatal parse errors go + // through TF_RUNTIME_ERROR so they are filterable apart from warnings + // in downstream logs. + bool anyError = false; for (const ObjIntermediate& inter : intermediates) { + for (const std::string& msg : inter.warnings) { + TF_WARN("%s", msg.c_str()); + } if (inter.error) { - return false; + if (!inter.errorMsg.empty()) { + TF_RUNTIME_ERROR("%s", inter.errorMsg.c_str()); + } + anyError = true; } } + if (anyError) { + return false; + } w.Stop(); TF_DEBUG_MSG(FILE_FORMAT_OBJ, "readObjIntermediate time: %ld\n", @@ -1209,9 +1250,13 @@ addImage(Obj& obj, obj.importedFilenames.insert(filename); if (readImages) { std::string fullFilename = parentPath + filename; - if (!readFileContents(fullFilename, - *(reinterpret_cast*>(&image.image)))) { + std::vector tempBuffer; + if (!readFileContents(fullFilename, tempBuffer)) { TF_WARN("Failed to load image file \"%s\"", fullFilename.c_str()); + } else { + image.image.assign(reinterpret_cast(tempBuffer.data()), + reinterpret_cast(tempBuffer.data()) + + tempBuffer.size()); } } } diff --git a/obj/src/objExport.cpp b/obj/src/objExport.cpp index d55bf0bd..564a1ed3 100644 --- a/obj/src/objExport.cpp +++ b/obj/src/objExport.cpp @@ -12,6 +12,7 @@ governing permissions and limitations under the License. #include "objExport.h" #include "debugCodes.h" #include +#include #include #include #include @@ -189,45 +190,65 @@ exportObj(const ExportObjOptions& options, const UsdData& usd, Obj& obj) image.image = usdImage.image; } - if (!usd.materials.empty()) { + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + size_t matCount = useOpenPbr ? usd.openPbrMaterials.size() : usd.materials.size(); + if (matCount > 0) { obj.libraries.push_back(ObjMaterialLibrary()); ObjMaterialLibrary& library = obj.libraries.back(); library.filename = name + ".mtl"; - obj.materials.resize(usd.materials.size()); - library.materials.resize(usd.materials.size()); + obj.materials.resize(matCount); + library.materials.resize(matCount); // Use the UniqueNameEnforcer to create a new list of unique material names. UniqueNameEnforcer uniqueMaterialNameEnforcer; std::vector uniqueNames; - uniqueNames.reserve(usd.materials.size()); - for (auto& m : usd.materials) { - uniqueNames.push_back(m.name); - uniqueMaterialNameEnforcer.enforceUniqueness(uniqueNames.back()); + uniqueNames.reserve(matCount); + if (useOpenPbr) { + for (auto& m : usd.openPbrMaterials) { + uniqueNames.push_back(m.name); + uniqueMaterialNameEnforcer.enforceUniqueness(uniqueNames.back()); + } + } else { + for (auto& m : usd.materials) { + uniqueNames.push_back(m.name); + uniqueMaterialNameEnforcer.enforceUniqueness(uniqueNames.back()); + } } - for (size_t i = 0; i < usd.materials.size(); i++) { - const Material& m = usd.materials[i]; + for (size_t i = 0; i < matCount; i++) { ObjMaterial& om = obj.materials[i]; library.materials[i] = i; om.name = uniqueNames[i]; - writeObjMaterialValue(om.kd, m.diffuseColor); - writeObjMaterialValue(om.ni, m.ior); - writeObjMaterialValue(om.d, m.opacity); - writeObjMap(usd, om.mapKd, m.diffuseColor); - writeObjMap(usd, om.norm, m.normal); - writeObjMap(usd, om.mapD, m.opacity); - writeObjMap(usd, om.disp, m.displacement); - // Note, the code above is only writing a small subset of the overall material model - // to the MTL library. This should be expanded. - // We'll keep the snippet below in case we want to use it again with a reworked material - // export. - // if (m.useSpecularWorkflow.valued && m.useSpecularWorkflow.value) { - // writeObjMaterialValue(om.ks, m.specularColor); - // writeObjMap(usd, om.mapKs, m.specularColor); - // } else { - // writeObjMap(usd, om.mapKs, m.metallic); - // } + if (useOpenPbr) { + const OpenPbrMaterial& m = usd.openPbrMaterials[i]; + writeObjMaterialValue(om.kd, m.base_color); + writeObjMaterialValue(om.ni, m.specular_ior); + writeObjMaterialValue(om.d, m.geometry_opacity); + writeObjMap(usd, om.mapKd, m.base_color); + writeObjMap(usd, om.norm, m.geometry_normal); + writeObjMap(usd, om.mapD, m.geometry_opacity); + writeObjMap(usd, om.disp, m.displacement); + } else { + const Material& m = usd.materials[i]; + writeObjMaterialValue(om.kd, m.diffuseColor); + writeObjMaterialValue(om.ni, m.ior); + writeObjMaterialValue(om.d, m.opacity); + writeObjMap(usd, om.mapKd, m.diffuseColor); + writeObjMap(usd, om.norm, m.normal); + writeObjMap(usd, om.mapD, m.opacity); + writeObjMap(usd, om.disp, m.displacement); + // Note, the code above is only writing a small subset of the overall material model + // to the MTL library. This should be expanded. + // We'll keep the snippet below in case we want to use it again with a reworked + // material export. if (m.useSpecularWorkflow.valued && m.useSpecularWorkflow.value) + // { + // writeObjMaterialValue(om.ks, m.specularColor); + // writeObjMap(usd, om.mapKs, m.specularColor); + // } else { + // writeObjMap(usd, om.mapKs, m.metallic); + // } + } } } diff --git a/obj/src/objImport.cpp b/obj/src/objImport.cpp index 0140af94..e025eca7 100644 --- a/obj/src/objImport.cpp +++ b/obj/src/objImport.cpp @@ -12,11 +12,16 @@ governing permissions and limitations under the License. #include "objImport.h" #include "debugCodes.h" #include +#include #include +// needed for kAsmToOpenPbrEmissionFactor, should be refactored and moved to a common header file +#include #include #include #include #include + +#include #include using namespace PXR_NS; @@ -94,8 +99,13 @@ importMaterialProperty(const ObjMap& map, const T& defaultValue = T(0.0f)) { if (map.defined) { - // If the value is zero we don't need a texture, since we know the result will be zero - if (value == T(0.0f)) { + // Suppress the texture only on an explicit zero that differs from the channel default: the + // result is then known to be zero (e.g. glow 0 with default -1, or d 0 with default 1). + // A zero that equals the default is a placeholder, not an explicit suppression - most + // notably Kd 0 0 0, which Maya writes when a texture is connected to the color. In that + // case the map is the authoritative source and must be kept, otherwise the surface renders + // as flat black instead of the texture. + if (value == T(0.0f) && value != defaultValue) { input.value = value; return true; } @@ -124,6 +134,47 @@ importMaterialProperty(const ObjMap& map, return false; } +// Derives a PBR roughness value from a Phong/Blinn specular color and shininess exponent, without +// the metallic solve. Plain OBJ (.mtl) materials carry no reflectivity or metalness channel, so +// they should stay dielectric (metalness 0); running the full phong-to-PBR metallic solve on them +// over-metallics painted surfaces whose bright specular reads as metal. Authoring roughness here +// also stops it from falling back to the schema default, which renders the surface too glossy or +// too matte depending on the target material model. +// +// NOTE: this duplicates the equivalent shininess-to-roughness conversion in the FBX importer. +// The two should be consolidated into a single shared helper once both import paths stabilize. +void +shininessToRoughness(InputTranslator& inputTranslator, + const Input& specular, + const Input& shininess, + Input& roughness) +{ + // Texture-backed specular/shininess: bake through the shared phong conversion and keep only + // the roughness output (the diffuse and metallic results are discarded). + if (specular.image >= 0 || shininess.image >= 0) { + Input unusedDiffuse; + Input unusedMetallic; + inputTranslator.translatePhong2PBR( + Input(), specular, shininess, unusedDiffuse, unusedMetallic, roughness); + return; + } + + // Value-based path: compute roughness directly, mirroring the roughness half of phongToPbr() + // without the metallic solve. alpha = sqrt(2 / (shininess * specularIntensity + 2)); very high + // shininess maps below the mirror threshold and is clamped to a perfect mirror (roughness 0). + constexpr float mirrorThreshold = 0.08f; + GfVec3f ks = (!specular.value.IsEmpty() && specular.value.IsHolding()) + ? specular.value.UncheckedGet() + : GfVec3f(0.5f); + float ns = shininess.value.IsHolding() ? shininess.value.UncheckedGet() : 0.5f; + float specularIntensity = 0.2125f * ks[0] + 0.7154f * ks[1] + 0.0721f * ks[2]; + float value = std::sqrt(2.0f / (ns * specularIntensity + 2.0f)); + if (value < mirrorThreshold) { + value = 0.0f; + } + roughness.value = value; +} + void importEmissive(const ObjMaterial& m, InputTranslator& inputTranslator, @@ -162,7 +213,7 @@ importObj(const ImportObjOptions& options, Obj& obj, UsdData& usd) { // The obj importer collects filenames in the Obj object- add these files to UsdData so that it // will be incorporated in the metadata - for (const std::string filename : obj.importedFilenames) { + for (const std::string& filename : obj.importedFilenames) { usd.importedFileNames.insert(filename); } @@ -173,63 +224,151 @@ importObj(const ImportObjOptions& options, Obj& obj, UsdData& usd) } if (options.importMaterials) { InputTranslator inputTranslator(options.importImages, obj.images, DEBUG_TAG); - usd.materials.resize(obj.materials.size()); - for (size_t i = 0; i < obj.materials.size(); i++) { - const ObjMaterial& m = obj.materials[i]; - Material& um = usd.materials[i]; - TF_DEBUG_MSG(FILE_FORMAT_OBJ, "Import material: %s\n", m.name.c_str()); - um.name = m.name; - Input diffuse; - Input roughness; - Input metallic; - Input specular; - Input glosiness; - Input normal; - Input bump; - Input opacity; - Input ior; - Input transmission; - Input absorptionColor; - importMaterialProperty(m.mapKd, diffuse, AdobeTokens->rgb, m.kd); - bool hasRoughness = - importMaterialProperty(m.mapRoughness, roughness, AdobeTokens->r, m.roughness); - bool hasMetallic = - importMaterialProperty(m.mapMetallic, metallic, AdobeTokens->r, m.metallic); - if (hasRoughness || hasMetallic) { - inputTranslator.translateDirect(diffuse, um.diffuseColor); - inputTranslator.translateDirect(metallic, um.metallic); - inputTranslator.translateDirect(roughness, um.roughness); - } else { - importMaterialProperty(m.mapKs, specular, AdobeTokens->rgb, m.ks); - importMaterialProperty(m.mapNs, glosiness, importChannel(m.mapNs.channel), m.ns); - if (options.importPhong) { - inputTranslator.translatePhong2PBR( - diffuse, specular, glosiness, um.diffuseColor, um.metallic, um.roughness); + if (isNativeOpenPbrProcessingEnabled()) { + + usd.openPbrMaterials.resize(obj.materials.size()); + for (size_t i = 0; i < obj.materials.size(); i++) { + const ObjMaterial& m = obj.materials[i]; + OpenPbrMaterial& um = usd.openPbrMaterials[i]; + TF_DEBUG_MSG(FILE_FORMAT_OBJ, "Import material: %s\n", m.name.c_str()); + um.name = m.name; + Input diffuse; + Input roughness; + Input metallic; + Input specular; + Input glosiness; + Input normal; + Input bump; + Input opacity; + Input ior; + Input transmission; + Input absorptionColor; + importMaterialProperty(m.mapKd, diffuse, AdobeTokens->rgb, m.kd); + bool hasRoughness = + importMaterialProperty(m.mapRoughness, roughness, AdobeTokens->r, m.roughness); + bool hasMetallic = + importMaterialProperty(m.mapMetallic, metallic, AdobeTokens->r, m.metallic); + if (hasRoughness || hasMetallic) { + inputTranslator.translateDirect(diffuse, um.base_color); + inputTranslator.translateDirect(metallic, um.base_metalness); + inputTranslator.translateDirect(roughness, um.specular_roughness); } else { - inputTranslator.translateDirect(diffuse, um.diffuseColor); + importMaterialProperty(m.mapKs, specular, AdobeTokens->rgb, m.ks); + importMaterialProperty( + m.mapNs, glosiness, importChannel(m.mapNs.channel), m.ns); + if (options.importPhong) { + inputTranslator.translatePhong2PBR(diffuse, + specular, + glosiness, + um.base_color, + um.base_metalness, + um.specular_roughness); + } else { + // Derive roughness from the shininess so glossiness isn't lost to the + // schema default. Metalness is left at its default (0): plain OBJ + // materials have no metalness channel, so skipping the full phong-to-PBR + // metallic solve keeps them dielectric instead of over-metallicking + // painted surfaces whose bright specular would read as metal. + inputTranslator.translateDirect(diffuse, um.base_color); + shininessToRoughness( + inputTranslator, specular, glosiness, um.specular_roughness); + } + } + importMaterialProperty(m.norm, normal, AdobeTokens->rgb, GfVec3f(-1)); + importMaterialProperty(m.bump, bump, importChannel(m.bump.channel), -1); + importMaterialProperty(ObjMap(), ior, TfToken(), m.ni, 1.5f); + + importEmissive(m, inputTranslator, diffuse, um.emission_color); + if (!um.emission_color.isEmpty()) { + um.emission_luminance = Input{ VtValue(kAsmToOpenPbrEmissionFactor) }; } - } - importMaterialProperty(m.norm, normal, AdobeTokens->rgb, GfVec3f(-1)); - importMaterialProperty(m.bump, bump, importChannel(m.bump.channel), -1); - importMaterialProperty(ObjMap(), ior, TfToken(), m.ni, 1.5f); - importEmissive(m, inputTranslator, diffuse, um.emissiveColor); + // mapOpacity is a mdl driven map and is a gray scale texture, it can always be read + // via the red channel + if (!importMaterialProperty(m.mapOpacity, opacity, AdobeTokens->r, m.d, 1.0f)) { + importMaterialProperty( + m.mapD, opacity, importChannel(m.mapD.channel), m.d, 1.0f); + } - // mapOpacity is a mdl driven map and is a gray scale texture, it can always be read via - // the red channel - if (!importMaterialProperty(m.mapOpacity, opacity, AdobeTokens->r, m.d, 1.0f)) { - importMaterialProperty(m.mapD, opacity, importChannel(m.mapD.channel), m.d, 1.0f); + inputTranslator.translateNormals(bump, normal, um.geometry_normal); + inputTranslator.translateDirect(opacity, um.geometry_opacity); + inputTranslator.translateDirect(ior, um.specular_ior); + + if (importMaterialProperty( + m.mapTranslucence, transmission, AdobeTokens->r, m.translucence)) { + inputTranslator.translateDirect(transmission, um.transmission_weight); + // Setup tinting of the translucent parts by the diffuse/base color + um.transmission_color = um.base_color; + } } + } else { - inputTranslator.translateNormals(bump, normal, um.normal); - inputTranslator.translateDirect(opacity, um.opacity); - inputTranslator.translateDirect(ior, um.ior); + usd.materials.resize(obj.materials.size()); + for (size_t i = 0; i < obj.materials.size(); i++) { + const ObjMaterial& m = obj.materials[i]; + Material& um = usd.materials[i]; + TF_DEBUG_MSG(FILE_FORMAT_OBJ, "Import material: %s\n", m.name.c_str()); + um.name = m.name; + Input diffuse; + Input roughness; + Input metallic; + Input specular; + Input glosiness; + Input normal; + Input bump; + Input opacity; + Input ior; + Input transmission; + Input absorptionColor; + importMaterialProperty(m.mapKd, diffuse, AdobeTokens->rgb, m.kd); + bool hasRoughness = + importMaterialProperty(m.mapRoughness, roughness, AdobeTokens->r, m.roughness); + bool hasMetallic = + importMaterialProperty(m.mapMetallic, metallic, AdobeTokens->r, m.metallic); + if (hasRoughness || hasMetallic) { + inputTranslator.translateDirect(diffuse, um.diffuseColor); + inputTranslator.translateDirect(metallic, um.metallic); + inputTranslator.translateDirect(roughness, um.roughness); + } else { + importMaterialProperty(m.mapKs, specular, AdobeTokens->rgb, m.ks); + importMaterialProperty( + m.mapNs, glosiness, importChannel(m.mapNs.channel), m.ns); + if (options.importPhong) { + inputTranslator.translatePhong2PBR( + diffuse, specular, glosiness, um.diffuseColor, um.metallic, um.roughness); + } else { + // Derive roughness from the shininess so glossiness isn't lost to the + // schema default. Metalness is left at its default (0): plain OBJ + // materials have no metalness channel, so skipping the full phong-to-PBR + // metallic solve keeps them dielectric instead of over-metallicking + // painted surfaces whose bright specular would read as metal. + inputTranslator.translateDirect(diffuse, um.diffuseColor); + shininessToRoughness(inputTranslator, specular, glosiness, um.roughness); + } + } + importMaterialProperty(m.norm, normal, AdobeTokens->rgb, GfVec3f(-1)); + importMaterialProperty(m.bump, bump, importChannel(m.bump.channel), -1); + importMaterialProperty(ObjMap(), ior, TfToken(), m.ni, 1.5f); - if (importMaterialProperty( - m.mapTranslucence, transmission, AdobeTokens->r, m.translucence)) { - inputTranslator.translateDirect(transmission, um.transmission); - // Setup tinting of the translucent parts by the diffuse/base color - um.absorptionColor = um.diffuseColor; + importEmissive(m, inputTranslator, diffuse, um.emissiveColor); + + // mapOpacity is a mdl driven map and is a gray scale texture, it can always be read + // via the red channel + if (!importMaterialProperty(m.mapOpacity, opacity, AdobeTokens->r, m.d, 1.0f)) { + importMaterialProperty( + m.mapD, opacity, importChannel(m.mapD.channel), m.d, 1.0f); + } + + inputTranslator.translateNormals(bump, normal, um.normal); + inputTranslator.translateDirect(opacity, um.opacity); + inputTranslator.translateDirect(ior, um.ior); + + if (importMaterialProperty( + m.mapTranslucence, transmission, AdobeTokens->r, m.translucence)) { + inputTranslator.translateDirect(transmission, um.transmission); + // Setup tinting of the translucent parts by the diffuse/base color + um.absorptionColor = um.diffuseColor; + } } } usd.images = std::move(inputTranslator.getImages()); @@ -357,6 +496,16 @@ importObj(const ImportObjOptions& options, Obj& obj, UsdData& usd) }; std::vector groupFaceRanges; + auto getGroupMaterial = [](const ObjGroup& g) -> int { + if (g.material >= 0) + return g.material; + if (g.subsets.size() == 1) + return g.subsets[0].material; + if (!g.subsets.empty()) + return g.subsets.back().material; + return -1; + }; + // Combine all groups for (const ObjGroup& g : o.groups) { if (g.faces.empty()) @@ -365,7 +514,7 @@ importObj(const ImportObjOptions& options, Obj& obj, UsdData& usd) // Track group face range for subset creation if (useSeparateGroupsAsSubsets) { groupFaceRanges.push_back( - { faceOffset, static_cast(g.faces.size()), g.material }); + { faceOffset, static_cast(g.faces.size()), getGroupMaterial(g) }); } // Append vertices (using push_back for USD version compatibility) @@ -441,8 +590,9 @@ importObj(const ImportObjOptions& options, Obj& obj, UsdData& usd) } // Track material for the combined mesh - if (g.material >= 0) { - current_material = g.material; + int gMat = getGroupMaterial(g); + if (gMat >= 0) { + current_material = gMat; } vertexOffset += g.vertices.size(); diff --git a/obj/src/plugInfo.json.in b/obj/src/plugInfo.json.in index f2ca4c6a..9e1d3c75 100644 --- a/obj/src/plugInfo.json.in +++ b/obj/src/plugInfo.json.in @@ -49,7 +49,7 @@ } } }, - "LibraryPath": "${PLUG_INFO_LIBRARY_PATH}", + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", "Name": "usdObj_plugin", "ResourcePath": "resources", "Root": "..", diff --git a/obj/tests/CMakeLists.txt b/obj/tests/CMakeLists.txt index 56f1befa..dd866067 100644 --- a/obj/tests/CMakeLists.txt +++ b/obj/tests/CMakeLists.txt @@ -9,6 +9,8 @@ PRIVATE usd GTest::gtest GTest::gtest_main + fileformatUtilsTest + gtestCommon ) gtest_add_tests(TARGET objSanityTests AUTO) diff --git a/obj/tests/sanityTests.cpp b/obj/tests/sanityTests.cpp index c6bc1ceb..931d4a6b 100644 --- a/obj/tests/sanityTests.cpp +++ b/obj/tests/sanityTests.cpp @@ -9,6 +9,8 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +#include +#include #include #include #include @@ -21,6 +23,6 @@ PXR_NAMESPACE_USING_DIRECTIVE TEST(OBJSanityTests, LoadCube) { - UsdStageRefPtr stage = UsdStage::Open("SanityCube.obj"); + UsdStageRefPtr stage = openAssetStage(assetDir + "SanityCube.obj"); ASSERT_TRUE(stage); } diff --git a/ply/CMakeLists.txt b/ply/CMakeLists.txt index 921b8cbe..9ee4b82c 100644 --- a/ply/CMakeLists.txt +++ b/ply/CMakeLists.txt @@ -1,6 +1,7 @@ option(NO_UNDEFINED "Active no-undefined compile options" ON) option(USD_FILEFORMATS_ENABLE_ASSET_TESTS "Build the more in depth unit tests using downloaded assets." OFF) -option(USDPLY_ENABLE_INSTALL "Enable installation of plugin artifacts" ON) +cmake_dependent_option(USDPLY_ENABLE_INSTALL "Enable installation of plugin artifacts" ON "USD_FILEFORMATS_ENABLE_INSTALL" OFF) + if (NOT TARGET usd) find_package(pxr REQUIRED) @@ -12,6 +13,10 @@ find_package(Happly REQUIRED) add_subdirectory(src) + +# Pass this list from the src/CMakeLists.txt to the parent scope +set(PLY_EXT_LIST ${PLY_EXT_LIST} PARENT_SCOPE) + if (USD_FILEFORMATS_BUILD_TESTS) add_subdirectory(tests) endif () @@ -20,3 +25,5 @@ endif () set(CPACK_INSTALL_CMAKE_PROJECTS "src;usdPly;ALL;/") include(CPack) + +fileformats_register_plugin("usdPly") diff --git a/ply/src/CMakeLists.txt b/ply/src/CMakeLists.txt index 1c71c36a..2feb9788 100644 --- a/ply/src/CMakeLists.txt +++ b/ply/src/CMakeLists.txt @@ -1,5 +1,7 @@ add_library(usdPly SHARED) +set(PLY_EXT_LIST "ply" PARENT_SCOPE) + usd_plugin_compile_config(usdPly) target_compile_definitions(usdPly PRIVATE USDPLY_EXPORTS) @@ -41,28 +43,39 @@ PRIVATE # Allow an option for deferring the path replacement to install time if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) - set(PLUG_INFO_LIBRARY_PATH "\$\{PLUG_INFO_LIBRARY_PATH\}") + # We still need to go through `configure_file` even with `USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT` because we burn additional CMake variable beyond PLUG_INFO_LIBRARY_PATH + # So we set `PLUG_INFO_LIBRARY_PATH` as a no op value and let other CMake variables being burnt in + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") else() set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}usdPly${CMAKE_SHARED_LIBRARY_SUFFIX}") endif() -configure_file(plugInfo.json.in plugInfo.json) -set_target_properties(usdPly PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) -set_target_properties(usdPly PROPERTIES RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") +configure_file(plugInfo.json.in plugInfo.json) +set_property(TARGET usdPly APPEND PROPERTY RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json") +set_property(TARGET usdPly APPEND PROPERTY RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") # USDPLY_DESTINATION is set in the parent scope by the add_usd_fileformat macro + if(USDPLY_ENABLE_INSTALL) + set_property(TARGET usdPly + APPEND PROPERTY + INSTALL_RPATH "${plugin_install_rpath_root}/." + ) install( TARGETS usdPly + EXPORT usd-fileformats-targets RUNTIME DESTINATION ${USDPLY_DESTINATION} COMPONENT Runtime LIBRARY DESTINATION ${USDPLY_DESTINATION} COMPONENT Runtime + ARCHIVE DESTINATION ${USDPLY_DESTINATION} COMPONENT Runtime RESOURCE DESTINATION ${USDPLY_DESTINATION}/usdPly/resources COMPONENT Runtime ) - install( - FILES plugInfo.root.json - DESTINATION ${USDPLY_DESTINATION} - RENAME plugInfo.json - COMPONENT Runtime - ) + if(USD_FILEFORMATS_ENABLE_INSTALL_PLUGINFO_ROOT) + install( + FILES plugInfo.root.json + DESTINATION ${USDPLY_DESTINATION} + RENAME plugInfo.json + COMPONENT Runtime + ) + endif() endif() diff --git a/ply/src/plugInfo.json.in b/ply/src/plugInfo.json.in index 886f306c..2286a791 100644 --- a/ply/src/plugInfo.json.in +++ b/ply/src/plugInfo.json.in @@ -39,7 +39,7 @@ } } }, - "LibraryPath": "${PLUG_INFO_LIBRARY_PATH}", + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", "Name": "usdPly_plugin", "ResourcePath": "resources", "Root": "..", diff --git a/ply/src/plyExport.cpp b/ply/src/plyExport.cpp index ad1869e5..334f0719 100644 --- a/ply/src/plyExport.cpp +++ b/ply/src/plyExport.cpp @@ -94,11 +94,22 @@ aggregateMeshInstance(PlyTotalMesh& totalMesh, } } else if (mesh.opacities[0].values.size() == mesh.faces.size()) { // in a case which we have colors or opacity per face, we need to add per vertex - // values since ply format needs per vertex color and opacity + // values since ply format needs per vertex color and opacity. Default any vertex + // not reached by a face (or skipped below) to opaque rather than transparent. + std::fill(totalMesh.opacity.begin() + opacityOffset, totalMesh.opacity.end(), 1.0f); for (size_t i = 0, k = 0; i < mesh.faces.size(); i++) { const float opacityValue = mesh.opacities[0].values[i]; for (int j = 0; j < mesh.faces[i]; j++) { - totalMesh.opacity[mesh.indices[k + j]] = opacityValue; + if (k + j >= mesh.indices.size()) + continue; + const int vertexIndex = mesh.indices[k + j]; + // The index references a vertex within this submesh, so write into this + // submesh's region using opacityOffset and guard against out-of-range + // indices to keep the write inside the allocated array. + if (vertexIndex >= 0 && + static_cast(vertexIndex) < currentMeshPointsSize) { + totalMesh.opacity[opacityOffset + vertexIndex] = opacityValue; + } } k += mesh.faces[i]; } @@ -120,11 +131,24 @@ aggregateMeshInstance(PlyTotalMesh& totalMesh, } } else if (mesh.colors[0].values.size() == mesh.faces.size()) { // in a case which we have colors or opacity per face, we need to add per vertex - // values since ply format needs per vertex color and opacity + // values since ply format needs per vertex color and opacity. Default any vertex + // not reached by a face (or skipped below) to white rather than black. + std::fill(totalMesh.color.begin() + colorOffset, + totalMesh.color.end(), + GfVec3f(1.0f, 1.0f, 1.0f)); for (size_t i = 0, k = 0; i < mesh.faces.size(); i++) { GfVec3f colorValue = mesh.colors[0].values[i]; for (int j = 0; j < mesh.faces[i]; j++) { - totalMesh.color[mesh.indices[k + j]] = colorValue; + if (k + j >= mesh.indices.size()) + continue; + const int vertexIndex = mesh.indices[k + j]; + // The index references a vertex within this submesh, so write into this + // submesh's region using colorOffset and guard against out-of-range + // indices to keep the write inside the allocated array. + if (vertexIndex >= 0 && + static_cast(vertexIndex) < currentMeshPointsSize) { + totalMesh.color[colorOffset + vertexIndex] = colorValue; + } } k += mesh.faces[i]; } @@ -147,7 +171,13 @@ aggregateMeshInstance(PlyTotalMesh& totalMesh, } } else { for (int j = 0; j < faceCount; j++) { - totalMesh.indices[indicesOffset + i][j] = mesh.indices[k + j] + pointsOffset; + // Guard the read and the resolved vertex index so malformed face/index data + // cannot read past mesh.indices or reference a vertex outside this submesh. + int vertexIndex = (k + j < mesh.indices.size()) ? mesh.indices[k + j] : 0; + if (vertexIndex < 0 || static_cast(vertexIndex) >= currentMeshPointsSize) { + vertexIndex = 0; + } + totalMesh.indices[indicesOffset + i][j] = vertexIndex + pointsOffset; } } k += faceCount; @@ -191,12 +221,12 @@ aggregateMeshInstance(PlyTotalMesh& totalMesh, GfVec3f normal = GfCross(v1 - v0, v2 - v0); GfVec3f xfNormal(normalMatrix.TransformDir(normal)); xfNormal.Normalize(); - for (size_t j = 0; j < nverts; j++) { + for (size_t j = 0; j < static_cast(nverts); j++) { totalMesh.normals[normalsOffset + k + j] = xfNormal; } } else { // The faces is degenerate, so we just assign a default value - for (size_t j = 0; j < nverts; j++) { + for (size_t j = 0; j < static_cast(nverts); j++) { totalMesh.normals[normalsOffset + k + j] = GfVec3f(0, 0, 1); } } @@ -404,15 +434,16 @@ exportPly(UsdData& usd, happly::PLYData& ply) if (m.asPoints) continue; - TF_DEBUG_MSG(FILE_FORMAT_PLY, - "mesh: faces:%d indices:%d pts:%d norInd:%d normals:%d uvInd:%d uvs:%d\n", - m.faces.size(), - m.indices.size(), - m.points.size(), - m.normals.indices.size(), - m.normals.values.size(), - m.uvs.indices.size(), - m.uvs.values.size()); + TF_DEBUG_MSG( + FILE_FORMAT_PLY, + "mesh: faces:%zu indices:%zu pts:%zu norInd:%zu normals:%zu uvInd:%zu uvs:%zu\n", + m.faces.size(), + m.indices.size(), + m.points.size(), + m.normals.indices.size(), + m.normals.values.size(), + m.uvs.indices.size(), + m.uvs.values.size()); expandIndexedValues(m.indices, m.points); if (m.uvs.indices.size()) { @@ -508,12 +539,28 @@ exportPly(UsdData& usd, happly::PLYData& ply) } TF_DEBUG_MSG(FILE_FORMAT_PLY, - "totalMesh: points=%d indices=%d normals=%d uvs=%d\n", + "totalMesh: points=%zu indices=%zu normals=%zu uvs=%zu\n", totalMesh.points.size(), totalMesh.indices.size(), totalMesh.normals.size(), totalMesh.uvs.size()); + // The color and opacity are written as per-vertex PLY properties, so their length must + // equal the vertex element count. Enforce this so happly never rejects the property (which + // throws and aborts the export) regardless of how the source color/opacity was laid out. + if (totalMesh.color.size() && totalMesh.color.size() != totalMesh.points.size()) { + TF_WARN("ply::export color array size (%zu) does not match vertex count (%zu); resizing", + totalMesh.color.size(), + totalMesh.points.size()); + totalMesh.color.resize(totalMesh.points.size(), GfVec3f(1.0f, 1.0f, 1.0f)); + } + if (totalMesh.opacity.size() && totalMesh.opacity.size() != totalMesh.points.size()) { + TF_WARN("ply::export opacity array size (%zu) does not match vertex count (%zu); resizing", + totalMesh.opacity.size(), + totalMesh.points.size()); + totalMesh.opacity.resize(totalMesh.points.size(), 1.0f); + } + if (totalMesh.points.size()) { std::string faceName = "face"; std::string vertexName = "vertex"; diff --git a/ply/src/plyImport.cpp b/ply/src/plyImport.cpp index 4f779b98..0c856579 100644 --- a/ply/src/plyImport.cpp +++ b/ply/src/plyImport.cpp @@ -355,7 +355,7 @@ importPly(const ImportPlyOptions& options, PLYData& ply, UsdData& usd) for (size_t i = 0; i < opacity.values.size(); i++) { // when nan opacity is detected, set opacity to 0 float op = (*gsOpacity)[i]; - opacity.values[i] = std::isfinite(op) ? 1.0f / (1.0f + std::exp(-op)) : 0.0f; + opacity.values[i] = std::isnan(op) ? 0.0f : (1.0f / (1.0f + std::exp(-op))); } } else if (a && a->size()) { auto [opacityIndex, opacity] = usd.addOpacitySet(meshIndex); @@ -425,7 +425,7 @@ importPly(const ImportPlyOptions& options, PLYData& ply, UsdData& usd) } if (numHighOrderSHCoeffs > 0) { - for (std::size_t shIndex = 0; shIndex < numHighOrderSHCoeffs; ++shIndex) { + for (int shIndex = 0; shIndex < numHighOrderSHCoeffs; ++shIndex) { auto [shCoeffIndex, shCoeffs] = usd.addPointSHCoeffSet(meshIndex); shCoeffs.interpolation = UsdGeomTokens->vertex; const auto& gsSHCoeffs = *(gsSHCoeffsLoaders[shIndex].getPropertyDataPtr()); diff --git a/ply/tests/CMakeLists.txt b/ply/tests/CMakeLists.txt index 8803cff7..39a284c1 100644 --- a/ply/tests/CMakeLists.txt +++ b/ply/tests/CMakeLists.txt @@ -9,6 +9,8 @@ PRIVATE usd GTest::gtest GTest::gtest_main + fileformatUtilsTest + gtestCommon ) gtest_add_tests(TARGET plySanityTests AUTO) diff --git a/ply/tests/sanityTests.cpp b/ply/tests/sanityTests.cpp index 715356e7..0bd6cfe1 100644 --- a/ply/tests/sanityTests.cpp +++ b/ply/tests/sanityTests.cpp @@ -9,6 +9,8 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +#include +#include #include #include #include @@ -21,8 +23,8 @@ TEST(PLYSanityTests, LoadCube) { PXR_NAMESPACE_USING_DIRECTIVE - // Load an FBX - UsdStageRefPtr stage = UsdStage::Open("SanityCube.ply"); + // Load a .ply file + UsdStageRefPtr stage = openAssetStage(assetDir + "SanityCube.ply"); ASSERT_TRUE(stage); } @@ -30,7 +32,7 @@ TEST(PLYSanityTests, LoadForeignCube) { PXR_NAMESPACE_USING_DIRECTIVE - // Load an FBX - UsdStageRefPtr stage = UsdStage::Open("貝殻ビューア Colored Cube.ply"); + // Load a .ply file + UsdStageRefPtr stage = openAssetStage(assetDir + "貝殻ビューア Colored Cube.ply"); ASSERT_TRUE(stage); } diff --git a/sandbox/CMakeLists.txt b/sandbox/CMakeLists.txt new file mode 100644 index 00000000..078855ea --- /dev/null +++ b/sandbox/CMakeLists.txt @@ -0,0 +1,31 @@ +option(USD_SANDBOXPROXY_ENABLE_INSTALL "Enable installation of plugin artifacts" ON) +set(TARGET_NAME usdSandbox) +project(${TARGET_NAME}) + +if (NOT TARGET usd) + find_package(pxr REQUIRED) +endif() + +find_package(Boost REQUIRED) +find_package(Python REQUIRED) + +if (APPLE OR UNIX) + set(CMAKE_MACOSX_RPATH TRUE) + # Process LD_LIBRARY_PATH if it's defined and not empty + if(DEFINED ENV{LD_LIBRARY_PATH} AND NOT "$ENV{LD_LIBRARY_PATH}" STREQUAL "") + # Read the LD_LIBRARY_PATH environment variable + string(REPLACE ":" ";" LD_LIBRARY_PATH_LIST "$ENV{LD_LIBRARY_PATH}") + set(CMAKE_INSTALL_RPATH "${LD_LIBRARY_PATH_LIST}") + endif() +endif() + +add_subdirectory(src/fileformat) +add_subdirectory(src/hardening) +add_subdirectory(src/protocol) +add_subdirectory(src/restricted) +add_subdirectory(src/resolver) +add_subdirectory(src/utilities) + +if(USD_FILEFORMATS_BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/sandbox/README.md b/sandbox/README.md new file mode 100644 index 00000000..c3eaa093 --- /dev/null +++ b/sandbox/README.md @@ -0,0 +1,273 @@ +# Fileformat Plugin Sandboxing + +Sandboxing is a **beta** feature: hardening is uneven across platforms and still being extended, the code is landing incrementally, and it shouldn't be relied on as a complete security boundary. + +Sandboxing runs a fileformat plugin in a separate, lower-privilege process, confining vulnerabilities internal to the plugin or in third-party libraries (e.g. the FBX SDK used by the FBX plugin) to that subprocess rather than the host. A **sandbox proxy** plugin facilitates this: when sandboxing is enabled for a format (such as FBX), the proxy handles assets of that type (`.fbx`) in place of the real plugin. It launches the sandboxed process, which loads the real plugin (kept in a separate location) and converts the asset exactly as it would unsandboxed — just with fewer permissions and without knowing it is sandboxed. + +

+ Sandboxed conversion flow: the host process opens a .fbx via the sandbox proxy plugin, which launches a lower-privilege subprocess where the real FBX plugin converts the asset, then returns the USD and textures to the host over shared memory +

+ +## Contents + +- [Building and enabling sandboxing](#building-and-enabling-sandboxing) +- [Security model and limitations](#security-model-and-limitations) +- [File format arguments](#file-format-arguments) +- [Notes and limitations](#notes-and-limitations) +- [Troubleshooting](#troubleshooting) +- [How it works](#how-it-works) + +## Building and enabling sandboxing + +Sandboxing is configured **per plugin at build time**. The choice is baked into the build and is used by any consumer, whether a USD tool or an application that links the plugins. Every per-plugin flag **defaults OFF**, so a build provides no isolation for a format unless that format's flag is explicitly enabled. + +These plugins can be sandboxed: + +- FBX +- GLTF +- OBJ +- PLY +- SPZ +- STL + +SBSAR does not support sandboxing. + +### Build flags + +Enable sandboxing for a format by passing `-DUSD_FILEFORMATS__SANDBOX=ON` as one of the `` on the standard configure line (see the build steps in the [main repository README](../README.md)): + +```bash +cmake -S . -B build -DCMAKE_INSTALL_PREFIX=bin -DUSD_FILEFORMATS_FBX_SANDBOX=ON ... +``` + +Sandboxing a format also requires that format's plugin to be enabled — `-DUSD_FILEFORMATS_ENABLE_FBX=ON`, which is on by default. The authoritative list of flags and their defaults lives in the top-level [`CMakeLists.txt`](../CMakeLists.txt). + +If you change a sandbox flag, **a clean build is required** to remove stale plugins from the sandbox or non-sandbox plugin folders. + +### Plugin folder layout + +Enabling sandboxing produces a **3-folder layout** — the normal plugins, the proxy, and the sandboxed (real) plugins each get their own directory: + +

+ Plugin folder layout under bin/: plugin/usd (normal plugins, seen by host and sandboxed process), plugin_proxy/usd (the sandbox proxy, seen by the host only), and plugin_sandboxed/usd (real plugins for sandboxed extensions, seen by the sandboxed process only) +

+ +Because only one plugin can register a given format, the proxy and the real sandboxed plugins cannot share a directory. They are kept apart so the host process finds the proxy while the subprocess finds the real plugins, and both are never registered together. + +### Using or bypassing sandboxing at runtime + +USD finds plugins via `PXR_PLUGINPATH_NAME` (entries separated by `:` on macOS/Linux, `;` on Windows). Sandboxing is selected at runtime by which directories you put on that path, so it can be turned on or off without rebuilding: + +- **To use sandboxing:** include the proxy directory. The sandboxed process is then launched automatically with a path that finds the real plugins but not the proxy. +- **To bypass sandboxing:** leave the proxy out and point directly at the sandboxed (real) plugins. + +```bash +# macOS / Linux + +# Use sandboxing: normal + proxy plugin directories +export PXR_PLUGINPATH_NAME="/path/to/bin/plugin/usd:/path/to/bin/plugin_proxy/usd" + +# Bypass sandboxing: normal + sandboxed (real) plugins directly, skipping the proxy +export PXR_PLUGINPATH_NAME="/path/to/bin/plugin/usd:/path/to/bin/plugin_sandboxed/usd" +``` + +```powershell +# Windows PowerShell + +# Use sandboxing +$env:PXR_PLUGINPATH_NAME = "C:\path\to\bin\plugin\usd;C:\path\to\bin\plugin_proxy\usd" + +# Bypass sandboxing +$env:PXR_PLUGINPATH_NAME = "C:\path\to\bin\plugin\usd;C:\path\to\bin\plugin_sandboxed\usd" +``` + +### Confirming sandboxing is active + +The sandbox is deliberately transparent, so a sandboxed conversion returns the same result as the in-process plugin, so there is no obvious external signal. The reliable indicator is a warning the host prints on every sandboxed import: + +``` +(HOST) Using sandbox to read asset: +``` + +Export currently prints no equivalent warning; use the debug flag below to confirm a sandboxed export. + +For a full trace, set `TF_DEBUG=FILE_FORMAT_SANDBOXPROXY` to print every sandbox proxy message, many tagged `(HOST)` or `(SANDBOX)` to show which process emitted them. The wildcard `TF_DEBUG=FILE_FORMAT*` also enables these (along with the other file-format plugins' debug output). + +### Runtime prerequisites + +On Linux, the worker enters new user, mount, and network namespaces via `unshare`. If the host doesn't permit unprivileged namespace creation, whether disabled by a kernel setting or restricted by a host or container security policy, the subprocess **fails to launch** (`sandbox: post-fork hardening failed to unshare namespaces; sandboxed process will not start`). This is common on hardened hosts and some default container runtimes. (Writing the uid/gid map is a softer requirement: if the namespace is created but the map write is denied, no action is needed; the worker still launches fully confined, just as the unmapped overflow user.) + +macOS and Windows have no comparable host prerequisite. + +## Security model and limitations + +Sandboxing is **one layer of defense, not the sole security control.** These plugins read attacker-controlled file content and must be memory-safe on their own; the sandbox reduces the blast radius of a vulnerability, it does not license unsafe parsing. + +It is also **opt-in and OFF by default.** Every per-plugin sandbox flag defaults OFF (`USD_FILEFORMATS__SANDBOX`), so a build gets **no isolation** unless a plugin is explicitly enabled. + +On every platform the parser runs in a **separate, lower-privilege subprocess**, so a crash or memory-corruption exploit is contained there rather than in the host process. The per-platform OS hardening below is layered on top of that isolation, and it is **uneven across platforms**. Do not assume a sandboxed parser is uniformly blocked from the filesystem, the network, or spawning processes; what actually holds on each platform is described below. + +### macOS (applied via `sandbox-exec`) + +Enforced: + +- **Default-deny sandbox profile** (`(deny default)`). File-content reads are limited to the asset source directory (import only), the temp directory, the required library paths, and internationalization libraries (`/usr/share/i18n`); writes are limited to the asset/export directory (export only) and the temp directory. +- **Network is denied** by the default-deny — no network rule is granted. + +Not enforced / broad: + +- **Broad temp access** — `/private/tmp` is granted both read and write, and `TMPDIR` is repointed there for the worker. +- `sandbox-exec` itself is **deprecated** by Apple (still widely used, including by Apple's own tools). + +### Windows + +Enforced: + +- **Import: the process integrity level is lowered to Low** (`S-1-16-4096`) before any untrusted parsing. Under Windows Mandatory Integrity Control this blocks the worker from *writing* to the higher-integrity objects the host and user own — files, registry keys, and named kernel objects at medium integrity or above — and from tampering with higher-integrity processes (no code injection, restricted window messages). + +Not enforced: + +- Low integrity is a **write-up restriction only** — it does not restrict reads or network egress, so the worker can still read most user-readable files and open network connections. **Network egress is not denied on Windows.** +- **Beyond low integrity there is no further OS confinement** — no AppContainer/lowbox token, restricted token, job-object limit, or process-mitigation policy. +- **Export applies no OS hardening at all** — the export subprocess is unsandboxed; only the process isolation above applies. + +### Linux + +Enforced: + +- **seccomp syscall filter** — a *blocklist* (default-allow with specific syscalls killed), **not** a default-deny allow-list. It kills process spawning (`execve`, `fork`, `vfork`), `ptrace`, `socket`/`connect` (network), and privilege/module/mount syscalls (`setuid`, `capset`, `mount`, `init_module`, `reboot`, …). +- **User, mount, and network namespaces** and **`no_new_privs`**. The network namespace isolates egress (redundant with the seccomp `socket`/`connect` kills). + +Not enforced: + +- **No capability drop** — capabilities already held are not dropped (the seccomp filter blocks `capset`, but that is not the same as dropping inherited capabilities). +- **No restricted filesystem view** — the mount namespace is created but never used to scope the filesystem, so the parser can still **read any file the mapped user can read.** + +### Network egress at a glance + +| Platform | Import worker | Export worker | +|---|---|---| +| macOS | Denied (default-deny profile) | Denied (same enforcement as import) | +| Windows | **Not denied** (low IL does not restrict network) | Not denied (export is unsandboxed) | +| Linux | Denied (network namespace + seccomp `socket`/`connect`) | Denied (same enforcement as import) | + +## File format arguments + +The sandbox proxy handles a small set of file format arguments on the host, consuming them before the argument map is sent to the sandboxed worker. They apply to any sandboxed format, but only take effect when that format is built with sandboxing enabled — with sandboxing off, the proxy is not in the pipeline and the argument is silently ignored. + +| Argument | Effect | +|---|---| +| `assetsPath` | Directory where processed textures are written during import. Under sandboxing this is also the workaround for textures that cannot otherwise be loaded from a separate process (see the texture note under [Notes and limitations](#notes-and-limitations)). The syntax is the same for every format (example below); see the per-format READMEs for defaults and format-specific behavior. | +| `sandboxAllowLargeAssets` | `true` lifts the cap on the asset size the sandboxed worker reports back to the host. By default the host rejects any reported size of 4 GiB or more. The cap guards the host against a compromised worker reporting an inflated size to force a large allocation, so enabling this disables that protection — use only for trusted inputs that are legitimately 4 GiB or larger. | + +```python +# Write textures to disk during import so they survive the sandboxed worker exiting +stage = Usd.Stage.Open("asset.gltf:SDF_FORMAT_ARGS:assetsPath=/path/to/textures") + +# Allow assets 4 GiB or larger (trusted inputs only) +stage = Usd.Stage.Open("asset.fbx:SDF_FORMAT_ARGS:sandboxAllowLargeAssets=true") +``` + +## Notes and limitations + +- **Debuggers can't step across the sandbox boundary** into the subprocess that loads the file. To debug a plugin's import/export code, bypass sandboxing (or turn its sandbox flag off) and rebuild. +- Sandboxed conversions launch a subprocess and copy the results over shared memory, so they may be slower than in-process conversion. +- **Imports reject asset data of 4 GiB or more by default.** This bounds the size the subprocess can ask the host to allocate. The `sandboxAllowLargeAssets` file format argument lifts the cap for trusted large inputs, at the cost of that safety check. +- **Some methods of loading textures are not yet supported under sandboxing.** Normally textures are loaded lazily on demand by re-invoking the plugin. With sandboxing, once the subprocess exits the real plugin is gone, so a texture requested later **from a different process will not be found.** Either request textures from the same process that first converted the asset, or use the `assetsPath` argument to have textures written out eagerly during the first conversion. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| On Linux, the subprocess won't launch: the host logs `(HOST) Failed to launch unsafe process`, and the child prints `sandbox: post-fork hardening failed to unshare namespaces; ...` to stderr | The host or container doesn't permit unprivileged namespace creation | See [Runtime prerequisites](#runtime-prerequisites) | +| No `(HOST) Using sandbox to read asset` warning on import | The proxy isn't on the plugin path, or the format wasn't built sandboxed | Add the proxy directory to `PXR_PLUGINPATH_NAME` (see [Using or bypassing sandboxing at runtime](#using-or-bypassing-sandboxing-at-runtime)); confirm the format's `_SANDBOX` build flag was set | +| Textures missing when accessed after import | The texture was requested from a different process after the worker exited | Use `assetsPath` to write textures during the first conversion — see the texture note under [Notes and limitations](#notes-and-limitations) | + +## How it works + +### High level + +1. A **sandbox proxy plugin** handles conversions of all sandboxed fileformats. +2. Under the hood, the proxy launches a separate process with fewer permissions and tells it which file to convert. +3. The **sandboxed process** loads the source file and converts it using the real plugin, alongside any referenced textures. +4. Using **shared memory** accessible by both processes, the sandboxed process sends the converted USD and all associated textures to the host process, then terminates. +5. The **host process** reads the converted USD and its textures from shared memory, shuts down the interprocess communication channels, and returns the resulting asset. + +### Detailed process + +#### For import (to USD) + +
+1. Use the sandbox proxy plugin + +- Fileformat plugins are registered with USD via a `plugInfo.json` that references the plugin and the extensions it opens. +- For sandboxed formats, the extensions are registered with the **sandbox proxy plugin** instead of the real (unsafe) plugin. For example, `.gltf`/`.glb` are opened by the proxy. +- The proxy is installed to its own directory (`plugin_proxy/usd`), which must be on `PXR_PLUGINPATH_NAME`. The real plugins are installed elsewhere (`plugin_sandboxed/usd`). + +
+ +
+2. Launch the sandboxed process + +- The proxy creates an inter-process communication (IPC) channel and launches a second process — the sandboxed process — which runs a separate executable that performs the conversion. Two-way anonymous pipes let the host and child communicate. The executable's location is read from the `plugInfo`. +- OS-level restrictions are then applied (see [Security model and limitations](#security-model-and-limitations)): + - **macOS:** the executable is launched under `sandbox-exec` with a generated default-deny profile. + - **Linux:** after launch the child enters restricted namespaces and installs a seccomp syscall filter before any untrusted parsing. + - **Windows:** the child lowers its own integrity level to Low before any untrusted code runs. +- To prevent the subprocess from re-discovering the proxy (which would cause infinite recursion), the proxy's directory is replaced with the sandboxed-plugins directory in the subprocess's `PXR_PLUGINPATH_NAME`. The subprocess registers the real plugins directly from that path. + +
+ +
+3. Convert the asset (in the sandboxed process) + +- The host sends the fileformat arguments to the subprocess over the pipes. +- The subprocess opens the source asset with the USD API, which finds and uses the real fileformat plugin. The result is an `SdfLayer` representing the converted asset. +- The layer is "exported" to an in-memory URI (prefixed `InMemory://`, extension `.usdc`) handled by a custom **InMemoryResolver**, which holds the resulting USDC data. +- The subprocess traverses the layer to find every referenced external asset (textures), saving each path once, so they can be loaded before the subprocess exits (afterward the real plugin is no longer available). + +
+ +
+4. Set up shared memory + +- All assets to send (the USDC data plus every referenced texture) are opened as `ArAsset` objects, and their total size is summed by an **AssetWriter**. +- The subprocess sends the total size to the host over the pipes. If the pipes close first, the host knows the conversion failed or crashed. +- The reported size is untrusted input: before allocating, the host rejects any size of 4 GiB or more (unless `sandboxAllowLargeAssets` is set), preventing a compromised worker from forcing an unbounded allocation. +- The host allocates a shared-memory block of the required size and sends its name back to the subprocess, which connects to it. + +
+ +
+5. Send the converted asset (subprocess → host) + +- The AssetWriter writes a table of contents (each asset's path and its offset) into shared memory, followed by each asset's data block (size + bytes). +- Once everything is written, the subprocess terminates. + +
+ +
+6. Read the converted asset (in the host process) + +- An **AssetReader** interprets the shared memory, iterating the table of contents to recover every asset and its path. +- The USDC asset (the `InMemory://….usdc` URI) is opened via the InMemoryResolver and registered. +- The remaining assets (textures) are stored in a **SandboxAssetCache**. +- The host exports the in-memory layer to the requested USD path. +- When a texture is later requested, a custom package resolver serves it from the SandboxAssetCache (the real plugin is no longer available in the host). + +
+ +#### For export (from USD) + +Export reuses the same proxy and subprocess launch as import, with the data flowing the other way: + +
+Export specifics + +- The host gathers all `ArAsset`s referenced by the USD to be exported. Each referenced asset path is rewritten with the `InMemory://` prefix so the subprocess reads it from shared memory rather than disk. +- The layer is packaged as an in-memory `ArAsset` (as in import), and the host writes the USDC data and all textures into shared memory. +- The subprocess reads them back (all under the `InMemory://` prefix, so they resolve through the InMemoryResolver), opens the in-memory layer, and exports it to the requested path using the real plugin. Because textures carry the in-memory prefix, they are found and exported automatically. + +
+ +Sandboxing on export matters less than on import: triggering a vulnerability in an external library is far easier with a native file of that type (e.g. an `.fbx`) than with a USD that must first be converted. On **macOS and Linux, export is sandboxed the same as import.** On **Windows, export still runs in a separate subprocess (process isolation) but no OS-level hardening is applied to it yet.** diff --git a/sandbox/images/3FolderSetup.svg b/sandbox/images/3FolderSetup.svg new file mode 100644 index 00000000..832f3cff --- /dev/null +++ b/sandbox/images/3FolderSetup.svg @@ -0,0 +1,463 @@ + + + + + + + + + + + + + + + + + + + + +bin/ + + + + + + + +plugin/usd/ + + + + + + + +plugin_proxy/usd/ + + + + + + + +plugin_sandboxed/usd/ + + + + + + + +normal (non-​sandboxed) plugins, e.g. sbsar, usdSkel, ... ​ ​seen by: host process  +  sandboxed process + + + + + + + +the sandbox proxy plugin ​ ​by: host process only + + + + + + + +real plugins for sandboxed extensions, e.g. fbx, gltf, ... ​ ​seen by: sandboxed process only \ No newline at end of file diff --git a/sandbox/images/SandboxDiagram.svg b/sandbox/images/SandboxDiagram.svg new file mode 100644 index 00000000..e916d9e9 --- /dev/null +++ b/sandbox/images/SandboxDiagram.svg @@ -0,0 +1,213 @@ + + + + + + + + + + + + +Sandbox Proxy Pluginregistered for .fbx in place of the real pluginSandboxed Process(lower privilege)Real FBX pluginconverts FBX asset to USD (+ textures) + + Launchsubprocesswith reducedpermissions + + Return USD +textures overshared memoryHost Process(Application, USD tools, etc...) + + Open ".fbx"with USD API + + Return resultingasset \ No newline at end of file diff --git a/sandbox/include/sandbox/api.h b/sandbox/include/sandbox/api.h new file mode 100644 index 00000000..9e33badb --- /dev/null +++ b/sandbox/include/sandbox/api.h @@ -0,0 +1,32 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#pragma once + +#include "pxr/base/arch/export.h" + +#if defined(PXR_STATIC) +#define USDSANDBOX_API +#define USDSANDBOX_API_TEMPLATE_CLASS(...) +#define USDSANDBOX_API_TEMPLATE_STRUCT(...) +#define USDSANDBOX_LOCAL +#else +#if defined(USDSANDBOX_EXPORTS) +#define USDSANDBOX_API ARCH_EXPORT +#define USDSANDBOX_API_TEMPLATE_CLASS(...) ARCH_EXPORT_TEMPLATE(class, __VA_ARGS__) +#define USDSANDBOX_API_TEMPLATE_STRUCT(...) ARCH_EXPORT_TEMPLATE(struct, __VA_ARGS__) +#else +#define USDSANDBOX_API ARCH_IMPORT +#define USDSANDBOX_API_TEMPLATE_CLASS(...) ARCH_IMPORT_TEMPLATE(class, __VA_ARGS__) +#define USDSANDBOX_API_TEMPLATE_STRUCT(...) ARCH_IMPORT_TEMPLATE(struct, __VA_ARGS__) +#endif +#define USDSANDBOX_LOCAL ARCH_HIDDEN +#endif diff --git a/sandbox/include/sandbox/debugCodes.h b/sandbox/include/sandbox/debugCodes.h new file mode 100644 index 00000000..161a6455 --- /dev/null +++ b/sandbox/include/sandbox/debugCodes.h @@ -0,0 +1,22 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include + +PXR_NAMESPACE_OPEN_SCOPE +TF_DEBUG_CODES(FILE_FORMAT_SANDBOXPROXY, SANDBOXPROXY_PACKAGE_RESOLVER) +PXR_NAMESPACE_CLOSE_SCOPE + +extern const std::string DEBUG_TAG; \ No newline at end of file diff --git a/sandbox/include/sandbox/fileformat/api.h b/sandbox/include/sandbox/fileformat/api.h new file mode 100644 index 00000000..06e98f27 --- /dev/null +++ b/sandbox/include/sandbox/fileformat/api.h @@ -0,0 +1,33 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include "pxr/base/arch/export.h" + +#if defined(PXR_STATIC) +#define USDSANDBOXPROXY_API +#define USDSANDBOXPROXY_API_TEMPLATE_CLASS(...) +#define USDSANDBOXPROXY_API_TEMPLATE_STRUCT(...) +#define USDSANDBOXPROXY_LOCAL +#else +#if defined(USDSANDBOXPROXY_EXPORTS) +#define USDSANDBOXPROXY_API ARCH_EXPORT +#define USDSANDBOXPROXY_API_TEMPLATE_CLASS(...) ARCH_EXPORT_TEMPLATE(class, __VA_ARGS__) +#define USDSANDBOXPROXY_API_TEMPLATE_STRUCT(...) ARCH_EXPORT_TEMPLATE(struct, __VA_ARGS__) +#else +#define USDSANDBOXPROXY_API ARCH_IMPORT +#define USDSANDBOXPROXY_API_TEMPLATE_CLASS(...) ARCH_IMPORT_TEMPLATE(class, __VA_ARGS__) +#define USDSANDBOXPROXY_API_TEMPLATE_STRUCT(...) ARCH_IMPORT_TEMPLATE(struct, __VA_ARGS__) +#endif +#define USDSANDBOXPROXY_LOCAL ARCH_HIDDEN +#endif \ No newline at end of file diff --git a/sandbox/include/sandbox/fileformat/fileFormat.h b/sandbox/include/sandbox/fileformat/fileFormat.h new file mode 100644 index 00000000..6fd0533e --- /dev/null +++ b/sandbox/include/sandbox/fileformat/fileFormat.h @@ -0,0 +1,88 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include + +PXR_NAMESPACE_OPEN_SCOPE + +// clang-format off +#define USDSANDBOXPROXY_FILE_FORMAT_TOKENS \ + ((Id, "sandbox")) \ + ((Version, FILE_FORMATS_VERSION)) \ + ((Target, "usd")) \ + ((SandboxExecutableRelativePath, "")) \ + ((UnsafePluginRelativePath, "")) \ + ((SandboxLibraryPath, "")) +// clang-format on + +TF_DECLARE_PUBLIC_TOKENS(UsdSandboxProxyFileFormatTokens, USDSANDBOXPROXY_FILE_FORMAT_TOKENS); +TF_DECLARE_WEAK_AND_REF_PTRS(UsdSandboxProxyFileFormat); +class ArAsset; + +/** + * Proxy plugin that allows for sandboxing other fileformat plugins. This plugin will be seen + * externally and act as an entry point for the sandboxed fileformat plugins, but under the hood, + * it will launch a separate sandboxed process with fewer permissions to perform the actual + * fileformat conversion operations. + */ +class USDSANDBOXPROXY_API UsdSandboxProxyFileFormat + : public SdfFileFormat + , public PcpDynamicFileFormatInterface +{ +public: + void ComposeFieldsForFileFormatArguments(const std::string& assetPath, + const PcpDynamicFileFormatContext& context, + FileFormatArguments* args, + VtValue* dependencyContextData) const override; + + bool CanRead(const std::string& file) const override; + + bool Read(SdfLayer* layer, const std::string& resolvedPath, bool metadataOnly) const override; + + bool ReadFromString(SdfLayer* layer, const std::string& str) const override; + + bool WriteToString(const SdfLayer& layer, + std::string* str, + const std::string& comment = std::string()) const override; + + bool WriteToStream(const SdfSpecHandle& spec, std::ostream& out, size_t indent) const override; + + bool WriteToFile(const SdfLayer& layer, + const std::string& filePath, + const std::string& comment = std::string(), + const FileFormatArguments& args = FileFormatArguments()) const override; + +protected: + SDF_FILE_FORMAT_FACTORY_ACCESS; + + virtual ~UsdSandboxProxyFileFormat(); + + UsdSandboxProxyFileFormat(); + std::filesystem::path _sandboxExecutablePath; + std::filesystem::path _proxyPluginPath; + std::filesystem::path _unsafePluginRoot; +}; + +PXR_NAMESPACE_CLOSE_SCOPE diff --git a/sandbox/include/sandbox/fileformat/sandboxProxyResolver.h b/sandbox/include/sandbox/fileformat/sandboxProxyResolver.h new file mode 100644 index 00000000..f7310333 --- /dev/null +++ b/sandbox/include/sandbox/fileformat/sandboxProxyResolver.h @@ -0,0 +1,50 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +namespace adobe::usd::sandbox { + +/** + * The sandbox proxy resolver will be called to open packaged textures that still reference the + * original asset and texture paths. These textures will have been read from the shared memory + * (from the sandboxed process) during import and cached, and this resolver will retrieve them. + */ +class SandboxProxyResolver : public PXR_NS::ArPackageResolver +{ +public: + SandboxProxyResolver(); + +private: + // TODO: Is this necessary to keep? + std::string Resolve(const std::string& resolvedPackagePath, + const std::string& packagedPath) override; + + /* + * Open the asset with the given resolved package path and resolved packaged path. The asset + * will be fetched from the cache if it exists, otherwise a nullptr will be returned. + * + * resolvedPackagePath: the path of the asset that was converted. + * resolvedPackagedPath: the name of the texture referenced from the asset. + * + * Returns the asset if it exists in the cache, otherwise a nullptr. + */ + std::shared_ptr OpenAsset(const std::string& resolvedPackagePath, + const std::string& resolvedPackagedPath) override; + + // TODO: Implement if necessary + void BeginCacheScope(PXR_NS::VtValue* data) override; + void EndCacheScope(PXR_NS::VtValue* data) override; +}; + +} diff --git a/sandbox/include/sandbox/hardening/hardening.h b/sandbox/include/sandbox/hardening/hardening.h new file mode 100644 index 00000000..fb2bf6d9 --- /dev/null +++ b/sandbox/include/sandbox/hardening/hardening.h @@ -0,0 +1,68 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once +#include +#include +#include +#include +#include + +namespace adobe::usd::sandbox::hardening { + +#if SANDBOX_IS_MACOS +// Temp directory the macOS sandbox profile grants access to (read + write). Lives in this public +// header so host-side code (e.g. ScopedPluginPathOverride) can reach it without pulling in the +// src-level sandboxProfile.h. +inline std::filesystem::path +GetTempDirMacOS() +{ + return std::filesystem::path("/private/tmp"); +} +#endif // SANDBOX_IS_MACOS + +// Inputs to build launch-time hardening. All three fields are consumed only by macOS sandbox +// profile generation; Linux and Windows ignore the entire struct (their BuildLaunchHardening +// takes no launch-time inputs). +struct USDSANDBOX_API LaunchHardeningArgs +{ + std::string resolvedPath; // asset path being converted + std::string sandboxAccessiblePaths; // colon-separated lib paths (macOS) + bool isExport = false; +}; + +// Data the host needs to be able to launch and sandbox the child process. +struct USDSANDBOX_API LaunchHardening +{ + std::string commandPrefix; // macOS: sandbox-exec -p ""; else "" + ipc::Process::PosixPreExecHook preExecHook; // Linux: namespaces hook; else nullptr +}; + +// HOST: produce the platform-appropriate hardening for launching the child process +USDSANDBOX_API LaunchHardening +BuildLaunchHardening(const LaunchHardeningArgs& args); + +// HOST: get the platform-appropriate shared-memory name prefix. The host appends a +// per-process disambiguator +USDSANDBOX_API std::string +GetShmNamePrefix(); + +// For child process: apply in-process restrictions at startup, after plugin registration but +// before any untrusted parsing. +// Linux: seccomp restrictions on dangerous calls +// Windows: integrity lowering +// macOS: no-op (hardening already applied at launch via sandbox-exec). +// Returns false on failure. +USDSANDBOX_API bool +ApplyProcessRestrictions(bool isExport); + +} // namespace adobe::usd::sandbox::hardening diff --git a/sandbox/include/sandbox/platformConfig.h b/sandbox/include/sandbox/platformConfig.h new file mode 100644 index 00000000..2ebdc5a6 --- /dev/null +++ b/sandbox/include/sandbox/platformConfig.h @@ -0,0 +1,36 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +// Define platform-specific macros +#if defined(_WIN32) || defined(_WIN64) +#define SANDBOX_PLATFORM_NAME "Windows" +#define SANDBOX_IS_WINDOWS 1 +#define SANDBOX_IS_MACOS 0 +#define SANDBOX_IS_LINUX 0 +#elif defined(__APPLE__) && defined(__MACH__) +#define SANDBOX_PLATFORM_NAME "macOS" +#define SANDBOX_IS_WINDOWS 0 +#define SANDBOX_IS_MACOS 1 +#define SANDBOX_IS_LINUX 0 +#elif defined(__linux__) +#define SANDBOX_PLATFORM_NAME "Linux" +#define SANDBOX_IS_WINDOWS 0 +#define SANDBOX_IS_MACOS 0 +#define SANDBOX_IS_LINUX 1 +#else +#define SANDBOX_PLATFORM_NAME "Unknown" +#define SANDBOX_IS_WINDOWS 0 +#define SANDBOX_IS_MACOS 0 +#define SANDBOX_IS_LINUX 0 +#endif diff --git a/sandbox/include/sandbox/protocol/assetReader.h b/sandbox/include/sandbox/protocol/assetReader.h new file mode 100644 index 00000000..eb13e7ee --- /dev/null +++ b/sandbox/include/sandbox/protocol/assetReader.h @@ -0,0 +1,126 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include + +#include +#include + +#include +#include + +// Forward declaration — full definition is in , included only by the .cpp. +namespace adobe::usd::ipc { +class SharedMemory; +} + +namespace adobe::usd::sandbox { + +/** + * The AssetReader allows for reading ArAssets from shared memory. It will deserialize the assets + * in one process after they have been written to shared memory by a separate AssetWriter in + * another process. + */ +class USDSANDBOX_API AssetReader +{ +public: + /** + * Constructor for the AssetReader. + * + * @param shm The SharedMemory region to read from. Must have been created/connected and must + * remain valid for the lifetime of the AssetReader. + */ + AssetReader(ipc::SharedMemory& shm); + + /** + * Read an asset with a given path from the binary data + * + * @param path The path of the asset to read + * + * @return The ArAsset for the asset, or nullptr if the asset was not found + */ + std::shared_ptr ReadAssetFromSharedMemory(const std::string& path); + + /** + * Extract all assets from the shared memory and process them with the given callback. + * + * @param processAssetCallback A callback function that will be called for each asset. The + * callback will be called with the path of the asset and the asset + * itself. + * + * @return True if the assets were extracted and processed successfully, false otherwise + */ + bool ProcessAssetsFromSharedMemory( + std::function& asset)> + processAssetCallback); + +private: + // SharedMemory region provided at construction time. Always valid. + ipc::SharedMemory& _shm; + + // Store the mapping of asset paths to their offsets in shared memory. The table is read from + // the shared memory and cached for easy access. + AssetTableOfContents _tableOfContents; + + /* + * Read the table of contents into the assetsOffsetsCache, so that an asset's location in + * shared memory can be found and stored with the asset's path. Then, when reading an asset, + * its location can simply be looked up in the cache and read directly. + * The table of contents is formatted as follows: + * 1. Number of assets in table (size_t) + * 2. For each asset: + * a. Path string (size_t + string) + * b. Asset size (size_t) + * c. Asset offset in shared memory (size_t) + * + * Note: This function assumes that the table of contents has been written to shared memory + * starting from the beginning of the memory block! It will start reading with an offset of 0! + * + * Returns true if the table of contents was read successfully, false otherwise. + */ + bool _ReadTableOfContentsIntoCache(); + + /* + * Read the size and offset of an asset from the binary data. This helper function allows for + * reading an asset from shared memory into a buffer, or into a stream. + * + * path: the path of the asset to read + * assetSize: a variable that will be set to the size of the asset. + * assetOffset: a variable that will be set to the offset of the asset in the binary data. + * + * Returns true if the size and offset were read successfully, false otherwise + */ + bool _ReadAssetSizeAndOffset(const std::string& path, size_t& assetSize, size_t& assetOffset); + + /* + * These functions are used to read data from the shared memory buffer. + * + * The first parameter(s) is the value(s) to be read. + * + * The last parameter is the number of bytes read so far, or in other words, the offset of the + * next byte to be read. It will be modified by the function to reflect the new offset. + * + * The return value is true if the data was read successfully, false otherwise. + */ + + bool _ReadSizeType(SandboxSizeType& value, size_t& bytesRead); + bool _ReadAndResizeString(std::string& string, size_t& bytesRead); + bool _ReadBuffer(size_t& size, void* buffer, size_t& bytesRead); + + // Helper: reads from _shm at the given offset into buffer. + bool _ReadRaw(size_t offset, void* buffer, size_t size); +}; + +} // namespace adobe::usd::sandbox diff --git a/sandbox/include/sandbox/protocol/assetSerializerUtil.h b/sandbox/include/sandbox/protocol/assetSerializerUtil.h new file mode 100644 index 00000000..3e322a4b --- /dev/null +++ b/sandbox/include/sandbox/protocol/assetSerializerUtil.h @@ -0,0 +1,49 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include +#include +#include + +namespace adobe::usd::sandbox { + +// Mapping of asset paths to their associated ArAsset object pointers +using AssetMap = std::unordered_map>; + +// Use size_t for the size type +using SandboxSizeType = size_t; + +/// Basic struct to hold information about an asset for use in the table of contents in the +/// AssetReader and AssetWriter. +struct AssetInfo +{ + size_t size; + size_t offset; +}; + +/// Holds data required for constructing the table of contents. +struct AssetTableOfContents +{ + // Total size of the table of contents in bytes. Note that this is only used for writing + size_t sizeInBytes = 0; + + // List of entries, each containing a path to the asset, and information about the asset + // that will be used to construct the table of contents. + std::map assetPathsAndInfo; +}; + +} // namespace adobe::usd::sandbox diff --git a/sandbox/include/sandbox/protocol/assetWriter.h b/sandbox/include/sandbox/protocol/assetWriter.h new file mode 100644 index 00000000..3167bbc0 --- /dev/null +++ b/sandbox/include/sandbox/protocol/assetWriter.h @@ -0,0 +1,147 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include + +#include +#include + +#include + +// Forward declaration — full definition is in , included only by the .cpp. +namespace adobe::usd::ipc { +class SharedMemory; +} + +namespace adobe::usd::sandbox { + +/** + * The AssetWriter allows for writing ArAssets to shared memory. It will serialize the assets in + * one process so they can be read by a separate AssetReader in another process with access to the + * shared memory. + */ +class USDSANDBOX_API AssetWriter +{ +public: + /** + * Constructor for the AssetWriter. + * + * @param shm The SharedMemory region to write to. Must have been created/connected and must + * remain valid for the lifetime of the AssetWriter. + * @param arAssets The assets to write to the shared memory. This should be a map of entries, + * each containing a path to reference the asset by, and the ArAsset pointer + * extracted from the USD layer. + */ + AssetWriter(ipc::SharedMemory& shm, const AssetMap& arAssets); + + /** + * Get the total size in bytes needed for storing the assets and the table of contents in + * the shared memory. The shared memory should be sized to at least this before writing. + * + * @return The total size in bytes needed for storing the assets and the table of contents in + * the shared memory. + */ + size_t GetSize() const; + + /** + * Write assets to the binary data. + * + * This function assumes that there is the required space in the shared memory for the assets + * and the table of contents. GetSize returns the total size in bytes required, so shared memory + * should be resized to at least this size. + * + * @return True if the assets were written successfully, false otherwise + */ + bool WriteAssetsToSharedMemory(); + +private: + // SharedMemory region provided at construction time. Always valid. + ipc::SharedMemory& _shm; + + // Store the mapping of asset paths to their offsets in shared memory. The table is constructed + // and stored here before being written to the shared memory. + AssetTableOfContents _tableOfContents; + + // Store the assets to write to the shared memory. + AssetMap _assetsToWrite; + + // The total size in bytes needed for storing the table of contents and the assets. Set in + // _SetupTableOfContents(). Shared memory must be at least this size before writing. + size_t _requiredSize = 0; + + /* + * Construct a table of contents that maps asset paths to their offsets in shared memory. + * The table of contents is formatted as follows: + * 1. Number of assets in table (size_t) + * 2. For each asset: + * a. Path string (size_t + string) + * b. Asset size (size_t) + * c. Asset offset in shared memory (size_t) + * + * This function does not write the table of contents to the shared memory, but rather sets it + * up internally so it can be easily written in the future. This is because the shared memory + * may need to be resized based on the size of the table of contents and the assets, before it + * can be written. + * + * Requires: _assetsToWrite has already been set + * + * This function will set the total size needed for storing the table of contents and the + * assets. + */ + void _SetupTableOfContents(); + + /* + * Write the pre-calculated table of contents to the binary data. + * + * Requires: _tableOfContents has already been set with the _SetupTableOfContents function + * + * bytesWritten: the number of bytes written so far. This will be modified to reflect the + * new offset after the table of contents is written. If no data has been + * written yet, this should be 0. + * + * Returns true if the table of contents was written successfully, false otherwise. + */ + bool _WriteTableOfContents(size_t& bytesWritten); + + /* + * These functions are used to write data to the shared memory buffer. + * + * The first parameter(s) is/are the value(s) to be written. + * + * The last parameter is the number of bytes written so far, or in other words, the offset of + * the next byte to be written. It will be modified by the function to reflect the new offset. + * + * If these functions are updated, then the following Increment functions should be updated as + * well. They help calculate the size of data that will be written, without actually writing to + * shared memory. This is needed for calculating the size of the table of contents. + * + * The return value is true if the data was written successfully, false otherwise. + */ + + bool _WriteSizeType(SandboxSizeType value, size_t& bytesWritten); + bool _WriteSizeAndString(const std::string& string, size_t& bytesWritten); + // Warning: this function does not write the size of the buffer, it must be tracked separately + bool _WriteBuffer(size_t size, const void* buffer, size_t& bytesWritten); + + // Helper: writes buffer to _shm at the given offset. + bool _WriteRaw(const void* buffer, size_t size, size_t offset); + + // Helper functions for writing the table of contents. These are used to calculate how many + // bytes would be written by the corresponding write function + static void _IncrementBySizeType(size_t& bytesWritten); + static void _IncrementBySizeAndString(const std::string& string, size_t& bytesWritten); +}; + +} // namespace adobe::usd::sandbox diff --git a/sandbox/include/sandbox/protocol/hostProtocol.h b/sandbox/include/sandbox/protocol/hostProtocol.h new file mode 100644 index 00000000..efadd5c1 --- /dev/null +++ b/sandbox/include/sandbox/protocol/hostProtocol.h @@ -0,0 +1,183 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace adobe::usd::sandbox { + +/** + * Ceiling (4 GiB - 1) on the shared-memory size the host will allocate for a worker-reported asset + * size, unless the caller opts into larger transfers. The reported size is untrusted input from the + * sandboxed worker, so bounding it stops a malicious or buggy worker from driving an unbounded host + * allocation. + */ +constexpr size_t kMaxSharedMemorySize = std::numeric_limits::max(); + +/** + * Validate an untrusted worker-reported asset size against kMaxSharedMemorySize and return whether + * it may be accepted. When the size is over the cap this warns either way: rejecting it, or (with + * allowLargeAssets) accepting it while noting the host is no longer guarded against a compromised + * worker inflating its size. + * + * @param assetsSize The size reported by the sandboxed worker. + * @param allowLargeAssets Accept sizes above kMaxSharedMemorySize for legitimate large assets. + * + * @return true if the size may be accepted. + */ +USDSANDBOX_API bool +ValidateReportedAssetSize(size_t assetsSize, bool allowLargeAssets); + +/** + * Tracks the state of the host-side sandbox protocol. Each method enforces at runtime + * that the protocol is in the correct state before proceeding, posting a coding error + * and returning false otherwise. + */ +enum class HostState +{ + Initialized, + ProcessLaunched, + ArgsSent, + SizeReceived, + ShmCreated, + ShmReady, + Completed +}; + +/** + * Manages the host side of the sandbox communication protocol. Owns pipe handles, + * shared memory, and the child process. Enforces correct ordering of protocol steps. + * + * Usage (import): + * 1. Construct + * 2. LaunchProcess(...) + * 3. SendFileFormatArgs(...) + * 4. ReceiveAssetSize(...) + * 5. InitializeSharedMemory(...) + * 6. WaitForCompletion() + * + * Usage (export): + * 1. Construct + * 2. LaunchProcess(...) + * 3. SendFileFormatArgs(...) + * 4. CreateSharedMemory(...) [allocate only] + * 5. host writes the payload into GetSharedMemory() + * 6. AnnounceSharedMemory() [now safe for the worker to read] + * 7. WaitForCompletion() + */ +class USDSANDBOX_API HostProtocol : public ProtocolBase +{ +public: + HostProtocol(); + ~HostProtocol(); + + HostProtocol(const HostProtocol&) = delete; + HostProtocol& operator=(const HostProtocol&) = delete; + + /** + * Launch the sandboxed child process. + * + * @param processPath Path to the SandboxedProcess executable. + * @param resolvedPath Path to the asset being converted. + * @param unsafePluginRoot Root directory for unsafe plugins. + * @param isExport True if this is an export operation. + * @param preExecHook Optional hook called in the child (POSIX only) before exec. + * @param commandPrefix Optional prefix for the command (e.g. "sandbox-exec -p ..."). + * @return true if the process was launched successfully. + */ + bool LaunchProcess(const std::string& processPath, + const std::string& resolvedPath, + const std::string& unsafePluginRoot, + bool isExport, + ipc::Process::PosixPreExecHook preExecHook = nullptr, + const std::string& commandPrefix = ""); + + /// Send file format arguments to the sandbox process. + bool SendFileFormatArgs(const std::map& fileFormatArgs); + + /** + * Receive the total asset size from the sandbox process (import flow only). The size is + * untrusted input from the worker and is rejected if it exceeds kMaxSharedMemorySize, unless + * allowLargeAssets lifts that cap. + * + * @param assetsSize Set to the received size on success. + * @param allowLargeAssets Accept sizes above kMaxSharedMemorySize (for legitimate large + * assets). Defaults to false. + * + * @return true if a size within the accepted range was received. + */ + bool ReceiveAssetSize(size_t& assetsSize, bool allowLargeAssets = false); + + /** + * Allocate shared memory without announcing it to the worker. Valid from state SizeReceived + * (import) or ArgsSent (export). Transitions to ShmCreated. + * + * @param shmNamePrefix Platform-specific prefix for the shared memory name. + * @param dataSize Size in bytes of the shared memory to allocate. + * @return true if shared memory was created successfully. + */ + bool CreateSharedMemory(const std::string& shmNamePrefix, size_t dataSize); + + /** + * Send the stored shared memory name/size to the worker. Valid from state ShmCreated. + * Transitions to ShmReady. + * + * @return true if the announcement was sent successfully. + */ + bool AnnounceSharedMemory(); + + /** + * Import convenience: allocate shared memory and immediately announce it to the worker. + * Equivalent to CreateSharedMemory + AnnounceSharedMemory. Valid from state SizeReceived. + * On export, use CreateSharedMemory -> write payload -> AnnounceSharedMemory instead. + * + * @param shmNamePrefix Platform-specific prefix for the shared memory name. + * @param dataSize Size in bytes of the shared memory to allocate. + * @return true if shared memory was created and info sent successfully. + */ + bool InitializeSharedMemory(const std::string& shmNamePrefix, size_t dataSize); + + /// Wait for the sandbox process to finish and return success/failure. + bool WaitForCompletion(); + + HostState GetState() const { return _state; } + +private: + static constexpr int kShutdownTimeoutMs = 5000; + + HostState _state = HostState::Initialized; + + ipc::PipePair _toChildPipe; + ipc::PipePair _toParentPipe; + + std::unique_ptr _process; + + std::string _shmName; + size_t _shmSize = 0; + + static std::atomic sInstanceCount; +}; + +} // namespace adobe::usd::sandbox diff --git a/sandbox/include/sandbox/protocol/messageIO.h b/sandbox/include/sandbox/protocol/messageIO.h new file mode 100644 index 00000000..5326753e --- /dev/null +++ b/sandbox/include/sandbox/protocol/messageIO.h @@ -0,0 +1,50 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include + +#include +#include + +namespace adobe::usd::sandbox { + +/** + * Maximum accepted size, in bytes, of a single control message. Control messages + * (file-format args, asset size, shared-memory info) are small; the bulk asset payload travels + * through shared memory and is never sent as a message. This cap bounds the allocation a receiver + * performs in response to a length prefix that may be attacker-controlled (a buggy or compromised + * peer across the sandbox boundary). + */ +constexpr uint32_t kMaxMessageSize = 16u * 1024u * 1024u; // 16 MiB + +/** + * Write a length-prefixed message to the pipe: a 4-byte native-endian uint32 size followed by the + * bytes. Host and worker share an architecture (same machine, same build), so native byte order is + * safe on the wire. Returns false on short write, or if the message exceeds kMaxMessageSize. + */ +USDSANDBOX_API bool +WriteMessageToPipe(const ipc::PipeHandle& pipe, const std::vector& data); + +/** + * Read a length-prefixed message into out. Reads the 4-byte size first and rejects a declared + * size of 0 or one exceeding kMaxMessageSize BEFORE allocating or reading any body, so a + * hostile size cannot drive a large allocation. Returns false on read failure or a rejected size; + * out is left empty on failure. + */ +USDSANDBOX_API bool +ReadMessageFromPipe(const ipc::PipeHandle& pipe, std::vector& out); + +} // namespace adobe::usd::sandbox diff --git a/sandbox/include/sandbox/protocol/messages.h b/sandbox/include/sandbox/protocol/messages.h new file mode 100644 index 00000000..6e5d6de7 --- /dev/null +++ b/sandbox/include/sandbox/protocol/messages.h @@ -0,0 +1,60 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include + +#include +#include +#include + +namespace adobe::usd::sandbox { + +/// Message containing file format arguments sent from host to sandbox. +struct USDSANDBOX_API FileFormatArgsMessage +{ + std::map args; + + /// Serialize this message into @p writer. + void WriteTo(serialization::BufferWriter& writer) const; + /// Deserialize a message from @p reader; check reader.HasError() after calling. + static FileFormatArgsMessage ReadFrom(serialization::BufferReader& reader); +}; + +/// Message containing the total asset size, sent from sandbox to host during import +/// so the host can allocate shared memory of the correct size. +struct USDSANDBOX_API AssetSizeMessage +{ + size_t assetsSize = 0; + + /// Serialize this message into @p writer. + void WriteTo(serialization::BufferWriter& writer) const; + /// Deserialize a message from @p reader; check reader.HasError() after calling. + static AssetSizeMessage ReadFrom(serialization::BufferReader& reader); +}; + +/// Message containing shared memory name and size, sent from host to sandbox so it +/// can connect to the shared memory region. +struct USDSANDBOX_API SharedMemoryInfoMessage +{ + std::string name; + size_t size = 0; + + /// Serialize this message into @p writer. + void WriteTo(serialization::BufferWriter& writer) const; + /// Deserialize a message from @p reader; check reader.HasError() after calling. + static SharedMemoryInfoMessage ReadFrom(serialization::BufferReader& reader); +}; + +} // namespace adobe::usd::sandbox diff --git a/sandbox/include/sandbox/protocol/protocolBase.h b/sandbox/include/sandbox/protocol/protocolBase.h new file mode 100644 index 00000000..841ff63c --- /dev/null +++ b/sandbox/include/sandbox/protocol/protocolBase.h @@ -0,0 +1,60 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include + +#include +#include +#include + +namespace adobe::usd::sandbox { + +/** + * Shared implementation for the two ends of the sandbox protocol. Owns the shared-memory object + * and provides length-prefixed message send/receive over a caller-supplied pipe. Not instantiated + * directly (protected constructor); HostProtocol and SandboxProtocol derive from it and add their + * own pipe ownership, state machines, and handshake steps. + */ +class USDSANDBOX_API ProtocolBase +{ +public: + /** Get the shared memory object for reading/writing assets. Never null: the object is + * created at construction, and this fatally errors if it is somehow null. */ + ipc::SharedMemory& GetSharedMemory(); + + /// Clean up shared memory resources. + void CleanSharedMemory(); + +protected: + // sideTag: short label for diagnostics, e.g. "(HOST)" or "(SANDBOX)". + explicit ProtocolBase(const char* sideTag); + ~ProtocolBase(); + + ProtocolBase(const ProtocolBase&) = delete; + ProtocolBase& operator=(const ProtocolBase&) = delete; + + // Write a length-prefixed message over the given pipe. Returns false on failure. + bool WriteMessage(const ipc::PipeHandle& pipe, const std::vector& data); + + // Read a length-prefixed message from the given pipe into data. Returns false on failure. + bool ReadMessage(const ipc::PipeHandle& pipe, std::vector& data); + + const char* _sideTag; + std::unique_ptr _sharedMemory; +}; + +} // namespace adobe::usd::sandbox diff --git a/sandbox/include/sandbox/protocol/sandboxProtocol.h b/sandbox/include/sandbox/protocol/sandboxProtocol.h new file mode 100644 index 00000000..57d7d06b --- /dev/null +++ b/sandbox/include/sandbox/protocol/sandboxProtocol.h @@ -0,0 +1,86 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include +#include + +#include + +#include +#include + +namespace adobe::usd::sandbox { + +/// Tracks the state of the sandbox-side protocol. +enum class SandboxState +{ + Initialized, + ArgsReceived, + SizeSent, + ShmConnected, + Completed +}; + +/** + * Manages the sandbox (child) side of the communication protocol. Uses pipe handles + * received via command-line arguments to communicate with the host process. + * + * Usage (import): + * 1. Construct with pipe handles from argv + * 2. ReceiveFileFormatArgs(...) + * 3. SendAssetSize(...) + * 4. ReceiveAndConnectSharedMemory(...) + * 5. Write assets to shared memory + * + * Usage (export): + * 1. Construct with pipe handles from argv + * 2. ReceiveFileFormatArgs(...) + * 3. ReceiveAndConnectSharedMemory(...) + * 4. Read assets from shared memory + */ +class USDSANDBOX_API SandboxProtocol : public ProtocolBase +{ +public: + /** + * Construct the sandbox protocol with pipe handles from the command line. + * + * @param readPipeStr String representation of the read pipe handle (from argv). + * @param writePipeStr String representation of the write pipe handle (from argv). + */ + SandboxProtocol(const std::string& readPipeStr, const std::string& writePipeStr); + ~SandboxProtocol(); + + SandboxProtocol(const SandboxProtocol&) = delete; + SandboxProtocol& operator=(const SandboxProtocol&) = delete; + + /// Receive file format arguments from the host process. + bool ReceiveFileFormatArgs(std::map& fileFormatArgs); + + /// Send the total asset size to the host process (import flow only). + bool SendAssetSize(size_t assetsSize); + + /// Receive shared memory info from the host and connect to it. + bool ReceiveAndConnectSharedMemory(); + + SandboxState GetState() const { return _state; } + +private: + SandboxState _state = SandboxState::Initialized; + + ipc::PipeHandle _readPipe; + ipc::PipeHandle _writePipe; +}; + +} // namespace adobe::usd::sandbox diff --git a/sandbox/include/sandbox/resolver/api.h b/sandbox/include/sandbox/resolver/api.h new file mode 100644 index 00000000..6e8fdc7d --- /dev/null +++ b/sandbox/include/sandbox/resolver/api.h @@ -0,0 +1,32 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#pragma once + +#include "pxr/base/arch/export.h" + +#if defined(PXR_STATIC) +#define USDINMEMRESOLVER_API +#define USDINMEMRESOLVER_API_TEMPLATE_CLASS(...) +#define USDINMEMRESOLVER_API_TEMPLATE_STRUCT(...) +#define USDINMEMRESOLVER_LOCAL +#else +#if defined(USDINMEMRESOLVER_EXPORTS) +#define USDINMEMRESOLVER_API ARCH_EXPORT +#define USDINMEMRESOLVER_API_TEMPLATE_CLASS(...) ARCH_EXPORT_TEMPLATE(class, __VA_ARGS__) +#define USDINMEMRESOLVER_API_TEMPLATE_STRUCT(...) ARCH_EXPORT_TEMPLATE(struct, __VA_ARGS__) +#else +#define USDINMEMRESOLVER_API ARCH_IMPORT +#define USDINMEMRESOLVER_API_TEMPLATE_CLASS(...) ARCH_IMPORT_TEMPLATE(class, __VA_ARGS__) +#define USDINMEMRESOLVER_API_TEMPLATE_STRUCT(...) ARCH_IMPORT_TEMPLATE(struct, __VA_ARGS__) +#endif +#define USDINMEMRESOLVER_LOCAL ARCH_HIDDEN +#endif diff --git a/sandbox/include/sandbox/resolver/badAssetResolver.h b/sandbox/include/sandbox/resolver/badAssetResolver.h new file mode 100644 index 00000000..0453dc13 --- /dev/null +++ b/sandbox/include/sandbox/resolver/badAssetResolver.h @@ -0,0 +1,57 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include // kBadAssetScheme (shared with the quarantine helpers) + +#include +#include +#include + +#include +#include + +namespace adobe::usd::sandbox { + +/// Resolver for the `BadAsset://` quarantine scheme. Deliberately inert: `_Resolve` echoes a +/// well-formed `BadAsset://` path back unchanged (giving it a stable, opaque identity) and every +/// open returns null. It never touches the filesystem, the network, or any other resolver, so a +/// quarantined reference cannot be used to reach the bytes it names. +class USDINMEMRESOLVER_API BadAssetResolver : public PXR_NS::ArResolver +{ +public: + BadAssetResolver(); + ~BadAssetResolver() override; + +protected: + PXR_NS::ArResolvedPath _Resolve(const std::string& path) const override; + PXR_NS::ArResolvedPath _ResolveForNewAsset(const std::string& assetPath) const override; + + std::shared_ptr _OpenAsset( + const PXR_NS::ArResolvedPath& resolvedPath) const override; + + // Quarantined references are never writable; always returns null. + std::shared_ptr _OpenAssetForWrite( + const PXR_NS::ArResolvedPath& resolvedPath, + WriteMode writeMode) const override; + + std::string _CreateIdentifier(const std::string& assetPath, + const PXR_NS::ArResolvedPath& anchorAssetPath) const override; + + std::string _CreateIdentifierForNewAsset( + const std::string& assetPath, + const PXR_NS::ArResolvedPath& anchorAssetPath) const override; +}; + +} diff --git a/sandbox/include/sandbox/resolver/inMemoryResolver.h b/sandbox/include/sandbox/resolver/inMemoryResolver.h new file mode 100644 index 00000000..029fe002 --- /dev/null +++ b/sandbox/include/sandbox/resolver/inMemoryResolver.h @@ -0,0 +1,73 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace adobe::usd::sandbox { + +class InMemoryWritableAsset; + +class USDINMEMRESOLVER_API InMemoryResolver : public PXR_NS::ArResolver +{ +public: + InMemoryResolver(); + virtual ~InMemoryResolver(); + + // Methods to manage in-memory data + bool SetData(const std::string& uri, const std::vector& data); + bool GetData(const std::string& uri, std::vector& outData) const; + +protected: + // Override cache scope methods (no-op for in-memory) + void _BeginCacheScope(PXR_NS::VtValue* cacheScopeData) override; + void _EndCacheScope(PXR_NS::VtValue* cacheScopeData) override; + + // Override Resolve methods + PXR_NS::ArResolvedPath _Resolve(const std::string& path) const override; + PXR_NS::ArResolvedPath _ResolveForNewAsset(const std::string& assetPath) const override; + + // Override OpenAsset method for read operations + std::shared_ptr _OpenAsset( + const PXR_NS::ArResolvedPath& resolvedPath) const override; + + // Override OpenAssetForWrite method for write operations + std::shared_ptr _OpenAssetForWrite( + const PXR_NS::ArResolvedPath& resolvedPath, + WriteMode writeMode) const override; + + std::string _CreateIdentifier(const std::string& assetPath, + const PXR_NS::ArResolvedPath& anchorAssetPath) const override; + + std::string _CreateIdentifierForNewAsset( + const std::string& assetPath, + const PXR_NS::ArResolvedPath& anchorAssetPath) const override; + + bool _CanWriteAssetToPath(const PXR_NS::ArResolvedPath& resolvedPath, + std::string* whyNot) const override; + +private: + mutable std::unordered_map> _storage; +}; +} diff --git a/sandbox/include/sandbox/resolver/inMemoryWritableAsset.h b/sandbox/include/sandbox/resolver/inMemoryWritableAsset.h new file mode 100644 index 00000000..db868580 --- /dev/null +++ b/sandbox/include/sandbox/resolver/inMemoryWritableAsset.h @@ -0,0 +1,42 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include + +namespace adobe::usd::sandbox { + +class InMemoryWritableAsset : public PXR_NS::ArWritableAsset +{ +public: + InMemoryWritableAsset(std::vector& data); + virtual ~InMemoryWritableAsset(); + + // Close doesn't do anything for the in memory buffer + bool Close() override; + + // Override Write method for writing data + size_t Write(const void* buffer, size_t count, size_t offset) override; + + // Retrieve the in-memory buffer + const std::vector& GetBuffer() const; + +private: + // This is a reference so that it modifies the resolver's storage + // TODO: Replace with shared ptr + std::vector& _buffer; +}; +} diff --git a/sandbox/include/sandbox/utilities/base64url.h b/sandbox/include/sandbox/utilities/base64url.h new file mode 100644 index 00000000..6e13e326 --- /dev/null +++ b/sandbox/include/sandbox/utilities/base64url.h @@ -0,0 +1,53 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include +#include + +namespace adobe::usd::sandbox::base64url { + +/** + * Encode arbitrary bytes as base64url (RFC 4648 §5): the URL- and filename-safe alphabet + * (@c A-Z @c a-z @c 0-9 @c - @c _), with padding (@c =) stripped. The result contains none of + * @c + @c / @c =, so it is safe to embed in a URI scheme, filename, log line, or terminal. + * + * @param bytes The raw (possibly attacker-controlled, possibly non-UTF-8) bytes to encode. + * + * @return The base64url text. Bytes are treated as @c unsigned, so values above 127 encode + * correctly regardless of the platform's @c char signedness. + */ +USDSANDBOX_API +std::string +encode(std::string_view bytes); + +/** + * Decode base64url text back to the original bytes. Total and non-throwing: any malformed input + * yields @c std::nullopt rather than an exception or partial result. + * + * Accepts both padded and unpadded input. Rejects any character outside the base64url alphabet + * (notably the standard-base64 @c + and @c /) and any length that is impossible for base64 + * (@c ≡1 (mod 4) after removing padding). + * + * @param text The base64url text to decode. + * + * @return The decoded bytes, or @c std::nullopt if @p text is not valid base64url. + */ +USDSANDBOX_API +std::optional +decode(std::string_view text); + +} diff --git a/sandbox/include/sandbox/utilities/quarantine.h b/sandbox/include/sandbox/utilities/quarantine.h new file mode 100644 index 00000000..5831b891 --- /dev/null +++ b/sandbox/include/sandbox/utilities/quarantine.h @@ -0,0 +1,70 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +// Forward-declare SdfLayer handle types -- avoids pulling sdf/layer.h (and its boost/python +// transitive dependency) into consumers that only need the lightweight quarantine helpers. The +// typedef is identical to the one in sdf/layer.h, so including both in one TU is harmless. +#include +PXR_NAMESPACE_OPEN_SCOPE +SDF_DECLARE_HANDLES(SdfLayer); +PXR_NAMESPACE_CLOSE_SCOPE + +#include +#include +#include +#include + +namespace adobe::usd::sandbox { + +/// The inert URI scheme (including the `://` separator) that quarantined references are encoded +/// into. Resolved by BadAssetResolver (in the sandbox resolver plugin) to nothing. Defined here, +/// in the lowest layer, so both the quarantine helpers and the resolver share one definition. +inline constexpr std::string_view kBadAssetScheme = "BadAsset://"; + +/// Encode an attacker-controlled reference into an inert `BadAsset://` quarantine URI. +/// Idempotent: if @p originalRef is already quarantined it is returned unchanged (no nesting on +/// re-import or round-trip). The encoded form is safe to surface in logs/usdcat/terminals. +USDSANDBOX_API +std::string +QuarantineReference(std::string_view originalRef); + +/// True if @p assetPath is a `BadAsset://` quarantine URI. +USDSANDBOX_API +bool +IsQuarantined(std::string_view assetPath); + +/// Decode a quarantine URI back to the original reference. Decode-only -- opens nothing. +/// +/// Returns @c std::nullopt if @p badAssetUri is not a well-formed `BadAsset://` URI, if the +/// payload is not valid base64url, OR if the decoded bytes contain a NUL (never legitimate; this +/// closes a NUL-truncation allow-list bypass in host re-resolve code). The returned string is +/// attacker-controlled and may still hold other non-printing bytes -- callers MUST sanitize before +/// display and canonicalize + allow-list-check (on the exact bytes they open) before opening. +USDSANDBOX_API +std::optional +RevealQuarantinedReference(std::string_view badAssetUri); + +/// Collect every quarantined (`BadAsset://`) reference authored on @p layer, across both default +/// and time-sampled asset values. Returns the encoded URIs. +/// +/// Reports only references a prior scrub already quarantined; it does not itself detect or +/// quarantine unsafe paths. On an un-scrubbed layer it returns only whatever `BadAsset://` values +/// happen to be present. +USDSANDBOX_API +std::vector +CollectQuarantinedReferences(const PXR_NS::SdfLayerHandle& layer); + +} diff --git a/sandbox/include/sandbox/utilities/sandboxAssetCache.h b/sandbox/include/sandbox/utilities/sandboxAssetCache.h new file mode 100644 index 00000000..5baab7ac --- /dev/null +++ b/sandbox/include/sandbox/utilities/sandboxAssetCache.h @@ -0,0 +1,77 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include + +#include "pxr/usd/ar/asset.h" + +#include +#include + +namespace adobe::usd::sandbox { + +/** + * Singleton class for storing texture assets that have been converted in a sandbox. Those assets + * will be passed through shared memory from a sandboxed process. By the time the asset resolver + * will likely need to access them, the shared memory will have been destroyed. This class + * provides a way to store the assets so that the SandboxProxyResolver can access them. + * + * TODO: Add garbage collection + */ +class USDSANDBOX_API SandboxAssetCache +{ +public: + /// Return the process-wide singleton instance. + static SandboxAssetCache& GetInstance(); + + /** + * Insert or replace an asset in the cache under @p path. + * + * @param path The authored asset path used as the cache key. + * @param asset The resolved asset to store. + */ + void AddImageToCache(const std::string& path, const std::shared_ptr& asset); + + /** + * Remove the cached entry for @p path, if present. + * + * @param path The authored asset path to evict. + */ + void RemoveImageFromCache(const std::string& path); + + /** + * Look up a cached asset by its authored path. + * + * @param path The authored asset path to search for. + * @return The cached asset, or nullptr if not found. + */ + std::shared_ptr FindCachedAsset(const std::string& path); + + // This function may be useful for garbage collection. Taken from assetResolver.h + + // remove items from cache with an expiration period of 60 seconds + // and do not have the excludedPath key + // void garbageCollectCacheExcluding(const std::string& excludedPath); + +private: + SandboxAssetCache() = default; + SandboxAssetCache(const SandboxAssetCache& cache) = delete; + SandboxAssetCache& operator=(const SandboxAssetCache& cache) = delete; + + std::mutex _assetCacheMutex; + AssetMap _assetCache; +}; + +} // namespace adobe::usd \ No newline at end of file diff --git a/sandbox/include/sandbox/utilities/utilities.h b/sandbox/include/sandbox/utilities/utilities.h new file mode 100644 index 00000000..b15f1e8a --- /dev/null +++ b/sandbox/include/sandbox/utilities/utilities.h @@ -0,0 +1,210 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include +#include + +// Forward declarations for SdfLayer types — avoids pulling sdf/layer.h (and its boost/python +// transitive dependency) into every consumer that only needs lightweight utilities. +#include + +// Forward-declare ArAsset so that GetArAssets() can reference std::shared_ptr +// without requiring ar/asset.h in this header. +PXR_NAMESPACE_OPEN_SCOPE +class ArAsset; +PXR_NAMESPACE_CLOSE_SCOPE + +#include +#include +#include +#include +#include +#include +#include + +// TODO: Capitalize functions, add comments, etc... + +namespace adobe::usd::sandbox { + +/// The URI used to identify the in-memory USD data. +inline const std::string inMemoryURI = "InMemory://myfile.usdc"; + +/// Get the directory that the running executable is in +USDSANDBOX_API +std::filesystem::path +getExecutableDirectory(); + +/** + * Read a boolean file format argument and remove it from the map. Consumed (host-only) arguments + * are erased so they are not forwarded to the sandboxed worker. Only the exact value "true" yields + * true; any other value, or an absent key, yields false. + * + * @param args The file format arguments map, modified in place (the key is erased if present). + * @param key The argument key to consume. + * + * @return The parsed boolean value. + */ +USDSANDBOX_API bool +ConsumeBoolArg(std::map& args, const std::string& key); + +// Template function declaration and definition +template +std::string +toString(const T& value) +{ + // TODO: to lower overhead, replace with std::to_string() + std::stringstream ss; + ss << value; + return ss.str(); +} + +/** + * Normalize a path to use forward slashes. All backslashes are replaced with forward slashes. + * + * @param path The path to normalize + * + * @return The normalized path + */ +std::string +NormalizePath(const std::string& path); + +/** + * Find all asset properties on a SdfLayer containing packaged paths + * + * Both the default value and every time-sampled (animated) value of each asset-typed property are + * visited, so a reference hidden in an animated asset attribute is not missed. + * + * The resulting map will contain both the authored asset path and the resolved asset path. The + * former is used for referencing the texture and is the string as found in the USD data. The + * latter may be processed to be a more accurate path for finding the actual asset, becoming an + * absolute path with normalized forward slashes. This will be the case if, for instance, the + * original path referenced in the USDC is authored with Windows backslashes, but the current + * device is Mac or Linux and wouldn't be able to read that path. + * + * @param layer The layer to find asset properties on + * + * @return An unordered map of asset paths found on the layer + */ +USDSANDBOX_API +std::unordered_map +FindAssetPaths(PXR_NS::SdfLayerRefPtr layer); + +/** + * Find all asset properties on a SdfLayer containing packaged paths and modify them in the USDC + * data based on the given createNewAssetName function. + * + * The resulting map will contain both the authored asset path and the resolved asset path. The + * former is used for referencing the texture and is the string as found in the USD data. The + * latter may be processed to be a more accurate path for finding the actual asset, becoming an + * absolute path with normalized forward slashes. This will be the case if, for instance, the + * original path referenced in the USDC is authored with Windows backslashes, but the current + * device is Mac or Linux and wouldn't be able to read that path. + * + * @param layerToModify The layer to find and modify asset properties on. + * + * @param createNewAssetName A function that calculates a new asset name based on the old one, and + * returns whether that new name should be set in the USD data + * + * bool createNewAssetName(const std::string& authoredPath, std::string& newName): + * - The first parameter, authoredPath, is the authored path to the referenced asset. + * - The second, newName, is a reference to a string that should be updated to the new name for + * the asset in the USD data. + * The function returns a boolean that indicates whether the USD data should be updated with + * newName, the second parameter (passed by reference). + * + * @return An unordered map of asset paths found on the layer. If the USD data is changed by + * createNewAssetName(), the first element of each map entry will be altered accordingly. + * The second element will be calculated with getResolvedAssetPath(). These elements will + * usually be the same. + */ +USDSANDBOX_API +std::unordered_map +FindAndModifyAssetPaths( + PXR_NS::SdfLayerRefPtr layerToModify, + const std::function& createNewAssetName); + +/** + * For a function overview and descriptions of the first two parameters and return value, see + * documentation for + * std::unordered_map + * FindAndModifyAssetPaths( + * PXR_NS::SdfLayerRefPtr layerToModify, + * std::function createNewAssetName + * ) + * + * Normally, layerToModify is used to construct the resolved, absolute paths required to find the + * referenced assets. In some scenarios, though, a new anonymous layer must be created to be + * modified if the original is const. In these cases, it may not be able to be used to properly + * resolve referenced assets, since it doesn't have the context of an existing layer (such as a + * location on disk that referenced assets are relative to). + * + * In this scenario, assets must be resolved relative to a separate layer. This function provides + * an additional SdfLayer parameter which will be used to generate a resolved asset path instead + * of the original layerToModify. + * + * This resolved asset path will be the second entry in each pair in the returned map, and is used + * for finding the referenced asset. + * + * @param layerForResolvingAssets The SdfLayer used for resolving referenced asset paths. This is + * not an SdfLayerRefPtr but rather an SdfLayer, so that this function can be used within + * SdfFileFormat::Read(), which provides an SdfLayer. + * + */ +USDSANDBOX_API +std::unordered_map +FindAndModifyAssetPaths( + PXR_NS::SdfLayerRefPtr layerToModify, + const std::function& createNewAssetName, + const PXR_NS::SdfLayer& layerForResolvingAssets); + +/** + * Resolve the given asset paths into ArAssets that can be written to shared memory + * + * @param assets The (unordered) map of asset paths to write to the shared memory. The asset data + * will be loaded using the file format resolver with the given path. The first path + * in each pair should be the string as authored in the USD data, whereas the second + * should be a normalized absolute path where the asset can be found. + * + * @return A map of entries, each containing the asset's authored path and the resolved ArAsset + * pointer + */ +USDSANDBOX_API +std::unordered_map> +GetArAssets(const std::unordered_map& assets); + +/** + * Builds a new PXR_PLUGINPATH_NAME value by replacing the proxy plugin's path entry with the + * unsafe plugin root, preserving all other entries in the path list. This ensures the sandboxed + * process won't discover the proxy plugin (causing recursion or a crash) while still finding the + * actual sandboxed plugins and any other USD plugins. + * + * If proxyPluginPath is not found in the current path list, unsafePluginRoot is prepended so + * the sandboxed plugins are still discoverable. + * + * If proxyPluginPath is empty or cannot be canonicalized, the entire path list cannot be safely + * filtered since the proxy location is unknown. Instead, unsafePluginRoot is returned alone + * (clearing all other entries). + * + * @param currentPxrPluginPath The current value of PXR_PLUGINPATH_NAME + * @param proxyPluginPath The path entry to remove (the proxy plugin directory) + * @param unsafePluginRoot The replacement path (the sandboxed plugins directory) + * + * @return A new string to be used for PXR_PLUGINPATH_NAME + */ +USDSANDBOX_API +std::string +BuildNewPluginPath(const std::string& currentPxrPluginPath, + const std::string& proxyPluginPath, + const std::string& unsafePluginRoot); +} diff --git a/sandbox/path.h.in b/sandbox/path.h.in new file mode 100644 index 00000000..f7ea93ee --- /dev/null +++ b/sandbox/path.h.in @@ -0,0 +1,2 @@ +#pragma once +#define SANDBOX_PROFILE_PATH "@SANDBOX_PROFILE_PATH@" diff --git a/sandbox/sandbox_profile_template.sb.in b/sandbox/sandbox_profile_template.sb.in new file mode 100644 index 00000000..b6e0658c --- /dev/null +++ b/sandbox/sandbox_profile_template.sb.in @@ -0,0 +1,39 @@ +(version 1) +(deny default) +(debug allow) + +; Allow process execution, execvp else error 71 is thrown +(allow process-exec) +(allow sysctl-read) +(allow ipc-posix-shm-read*) +(allow ipc-posix-shm-write*) + +(allow file-read* + (literal "/") +) + +(allow file-read-metadata + (subpath "/Users") +) + +; Allow read and write access to the current working directory +(allow file-read* + (subpath "@CMAKE_SOURCE_DIR@") + (subpath "/private/tmp/") +) + +(allow file-write* + (subpath "@CMAKE_SOURCE_DIR@") + (subpath "/private/tmp/") +) + +;Allow access to python +(allow file-read* + (subpath "@PYTHON_MAIN@") +) + +; Allow paths in LD_LIBRARY_PATH +(allow file-read* + @LD_LIBRARY_PATH_RULES@ +) + diff --git a/sandbox/src/debugCodes.cpp b/sandbox/src/debugCodes.cpp new file mode 100644 index 00000000..93c5bdc2 --- /dev/null +++ b/sandbox/src/debugCodes.cpp @@ -0,0 +1,17 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +const std::string DEBUG_TAG = "SANDBOXPROXY"; \ No newline at end of file diff --git a/sandbox/src/fileformat/CMakeLists.txt b/sandbox/src/fileformat/CMakeLists.txt new file mode 100644 index 00000000..6d83a6ef --- /dev/null +++ b/sandbox/src/fileformat/CMakeLists.txt @@ -0,0 +1,138 @@ +add_library(usdSandboxProxy SHARED) + +usd_plugin_compile_config(usdSandboxProxy) +target_compile_definitions(usdSandboxProxy PRIVATE USDSANDBOXPROXY_EXPORTS) + +target_sources(usdSandboxProxy +PRIVATE + "fileFormat.cpp" + "sandboxProxyResolver.cpp" +) + +target_include_directories(usdSandboxProxy +PRIVATE + "${CMAKE_BINARY_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}/../../include" +) + +target_link_libraries(usdSandboxProxy +PRIVATE + usd + usdGeom + usdSkel + usdShade + fileformatUtils + sandboxUtils + sandboxProtocol + sandboxHardening +) + +# In the plugInfo.json, we need a list of sandboxed extensions. The fileformat extension list is +# case insensitive, and the resolver extension list is case sensitive, so we must generate both. + +# Helper function to filter a list, making it all lower case and removing duplicates +# case-insensitive. +function(filter_case_insensitive LIST FILTERED_LIST) + set(RESULT "") + foreach(EXTENSION IN LISTS ${LIST}) + # Append the lower case version of the extension to the result if it's not already in the + # list + string(TOLOWER "${EXTENSION}" EXTENSION_LOWER) + list(FIND RESULT "${EXTENSION_LOWER}" INDEX) + if (INDEX EQUAL -1) + list(APPEND RESULT "${EXTENSION_LOWER}") + endif() + endforeach() + + set(${FILTERED_LIST} "${RESULT}" PARENT_SCOPE) +endfunction() + +# Resolver extension list is case sensitive +list(JOIN USD_FILEFORMATS_SANDBOXED_EXTENSIONS "\",\"" CASE_SENSITIVE_EXTENSIONS_STRING) +set(CASE_SENSITIVE_EXTENSIONS_STRING "\"${CASE_SENSITIVE_EXTENSIONS_STRING}\"") + +# Fileformat extension list is case insensitive, so we must filter it to remove duplicates +filter_case_insensitive(USD_FILEFORMATS_SANDBOXED_EXTENSIONS SANDBOXED_EXTENSIONS_CASE_INSENSITIVE) +list(JOIN SANDBOXED_EXTENSIONS_CASE_INSENSITIVE "\",\"" CASE_INSENSITIVE_EXTENSIONS_STRING) +set(CASE_INSENSITIVE_EXTENSIONS_STRING "\"${CASE_INSENSITIVE_EXTENSIONS_STRING}\"") + +message(STATUS "Sandboxed extensions: ${CASE_INSENSITIVE_EXTENSIONS_STRING}") + +set(PLUG_INFO_SANDBOXED_EXTENSIONS_CASE_SENSITIVE ${CASE_SENSITIVE_EXTENSIONS_STRING}) +set(PLUG_INFO_SANDBOXED_EXTENSIONS_CASE_INSENSITIVE ${CASE_INSENSITIVE_EXTENSIONS_STRING}) + +# Installation of plugin files mimics the file structure that USD has for plugins, +# so it is easy to deploy it in a pre-existing USD build, if one chooses to do so. + +# Allow an option for deferring the path replacement to install time +if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") + + # For constructing the sandbox profile, in the situation where the plugins are being used or + # installed with another application. That application will define the library path variable. + # Note that it must include libraries the application uses, including Python, as well as + # access to any plugInfo.json files that the sandboxed process will need. This is a variable + # that is used on MacOS only, so on other platforms it should be replaced with an empty string + # or some other indicator that the variable is not used + set(PLUG_INFO_SANDBOX_LIBRARY_PATH "@PLUG_INFO_SANDBOX_LIBRARY_PATH@") + + set(PLUG_INFO_SANDBOX_EXECUTABLE_RELATIVE_PATH "@PLUG_INFO_SANDBOX_EXECUTABLE_RELATIVE_PATH@") + set(PLUG_INFO_PROXY_PLUGIN_PATH "@PLUG_INFO_PROXY_PLUGIN_PATH@") +else() + + set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}usdSandboxProxy${CMAKE_SHARED_LIBRARY_SUFFIX}") + # For constructing the sandbox profile, when the plugins are being built standalone. We must + # supply the location of libraries used (including Python) unless the given variable has + # already been defined externally. + if (APPLE) + get_filename_component(Python_BASE_DIR ${Python3_RUNTIME_LIBRARY_DIRS} DIRECTORY) + + # The variable may be defined externally if libraries are referenced some other way + # besides LD_LIBRARY_PATH + if (NOT DEFINED PLUG_INFO_SANDBOX_LIBRARY_PATH) + set(PLUG_INFO_SANDBOX_LIBRARY_PATH "$ENV{LD_LIBRARY_PATH}:${Python_BASE_DIR}") + endif() + else() + set(PLUG_INFO_SANDBOX_LIBRARY_PATH "NOT USED ON THIS OS") + endif() + + set(PLUGIN_INFO_UNSAFE_PLUGIN_RELATIVE_PATH "../../plugin_sandboxed/usd") + set(PLUG_INFO_SANDBOX_EXECUTABLE_RELATIVE_PATH "../../plugin_sandboxed/usd/SandboxedProcess") + # Relative paths in plugInfo.json are resolved at runtime relative to the dylib's directory, + # not relative to the plugInfo.json file itself. So "." resolves to the dylib's parent + # directory, which in a standalone build is the same directory USD scans for plugins (i.e. + # what appears in PXR_PLUGINPATH_NAME). For bundled app builds where the dylib and plugin + # discovery directory differ, PLUG_INFO_PROXY_PLUGIN_PATH must be set to the correct absolute + # path for that deployment. + if (NOT DEFINED PLUG_INFO_PROXY_PLUGIN_PATH) + set(PLUG_INFO_PROXY_PLUGIN_PATH ".") + endif() +endif() +configure_file(plugInfo.json.in plugInfo.json) +set_target_properties(usdSandboxProxy PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) + +set_target_properties(usdSandboxProxy PROPERTIES RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") + +if(WIN32) + target_include_directories(usdSandboxProxy + PRIVATE + "${pxr_ROOT}/include/boost-1_86" + ) +endif() + +# The sandbox proxy plugin is installed to a separate location from the normal plugins +if(USD_SANDBOXPROXY_ENABLE_INSTALL) + install( + TARGETS usdSandboxProxy + RUNTIME DESTINATION plugin_proxy/usd COMPONENT Runtime + LIBRARY DESTINATION plugin_proxy/usd COMPONENT Runtime + RESOURCE DESTINATION plugin_proxy/usd/usdSandboxProxy/resources COMPONENT Runtime + ) + + install( + FILES plugInfo.root.json + DESTINATION plugin_proxy/usd + RENAME plugInfo.json + COMPONENT Runtime + ) +endif() diff --git a/sandbox/src/fileformat/fileFormat.cpp b/sandbox/src/fileformat/fileFormat.cpp new file mode 100644 index 00000000..5ac98a77 --- /dev/null +++ b/sandbox/src/fileformat/fileFormat.cpp @@ -0,0 +1,999 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include "sandbox/fileformat/fileFormat.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +PXR_NAMESPACE_OPEN_SCOPE + +using namespace adobe::usd; + +TF_DEFINE_PUBLIC_TOKENS(UsdSandboxProxyFileFormatTokens, USDSANDBOXPROXY_FILE_FORMAT_TOKENS); + +TF_REGISTRY_FUNCTION(TfType) +{ + SDF_DEFINE_FILE_FORMAT(UsdSandboxProxyFileFormat, SdfFileFormat); +} + +namespace { + +// File format arguments handled on the host and erased before the argument map is sent to the +// sandboxed worker, so the worker never receives them. +const std::string kAssetsPathArg = "assetsPath"; +const std::string kAllowLargeAssetsArg = "sandboxAllowLargeAssets"; + +/* + * Simple class to override an environment variable on creation, and restore it to its original + * state in the destructor + * + * Because this class works by restoring the original value (and emitting a debug message) in the + * destructor, if added to a container, it should be added with emplace or emplace back so the + * destructor doesn't run sooner than intended + */ +class EnvVarOverride +{ +public: + /* + * Create an environment variable override, which will reset an environment variable for as + * long as this object exists. This constructor will replace the value, which will be restored + * by the destructor + * + * envVarToOverride: the name of the environment variable to be temporarily overridden + * newValue: an optional parameter for the value that will be temporarily given to the + * environment variable + */ + EnvVarOverride(const std::string& envVarToOverride, const std::string& newValue = "") + : _paramName(envVarToOverride) + , _originalValue(ArchGetEnv(_paramName)) + { + ArchSetEnv(_paramName.c_str(), newValue.c_str(), true); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Temporarily modifying environment variable %s.\n\tWas: \"%s\"\n\tNow " + "set to: \"%s\"\n", + _paramName.c_str(), + _originalValue.c_str(), + ArchGetEnv(_paramName).c_str()); + } + + ~EnvVarOverride() + { + ArchSetEnv(_paramName.c_str(), _originalValue.c_str(), true); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Restored environment variable %s.\n\tValue: \"%s\"\n", + _paramName.c_str(), + ArchGetEnv(_paramName).c_str()); + } + +private: + const std::string _paramName; + const std::string _originalValue; +}; + +/* + * A helper class that sets all necessary environment variables for relevant operating systems, + * and restores them when this object goes out of scope. This always updates PXR_PLUGINPATH_NAME + * by replacing the proxy plugin's path entry with the unsafe plugin root, so the sandboxed + * process finds the correct (sandboxed) plugins without discovering this proxy plugin. + * + * Different temp folders that the sandbox has access to are needed for different OS's. On Mac, + * TMPDIR is set this way, while on Windows, TMP and TEMP are set. This is not currently needed on + * Linux. + */ +class ScopedPluginPathOverride +{ +private: + // Store all environment variable overrides in this list. When this object goes out of scope, + // the list will be destructed, and all elements' destructors will restore the original + // environment variables + std::list _envVarOverrides; + +public: + /* + * Override relevant environment variables until the destructor is run. + * + * proxyPluginPath: the location where this plugin (SandboxProxy) is registered. This + * is likely the directory containing plugin's plugInfo.json, or a + * parent plugInfo that directs to the proxy plugin's. This entry will + * be replaced with unsafePluginRoot. This must be the same path as + * used in PXR_PLUGINPATH_NAME for finding this plugin. + * unsafePluginRoot: the sandboxed plugin's directory to substitute in. + */ + ScopedPluginPathOverride(const std::string& proxyPluginPath, + const std::string& unsafePluginRoot) + { + // Replace only the proxy plugin's entry in PXR_PLUGINPATH_NAME with the unsafe plugin + // root, preserving all other path entries + std::string newPluginPath = adobe::usd::sandbox::BuildNewPluginPath( + ArchGetEnv("PXR_PLUGINPATH_NAME"), proxyPluginPath, unsafePluginRoot); + _envVarOverrides.emplace_back("PXR_PLUGINPATH_NAME", newPluginPath); + + // Prevent the subprocess from registering the proxy plugin, when the proxy plugin is + // co-located with other usd plugins (such as in bundled app builds, where the proxy may + // be found with PXR_PLUGIN_BUILD_LOCATION preset. In other situations, this is redundant + // but harmless. +#if SANDBOX_IS_WINDOWS + const std::string pluginNameSep = ";"; +#else + const std::string pluginNameSep = ":"; +#endif + const std::string existingDisabledPlugins = ArchGetEnv("PXR_DISABLED_PLUGIN_NAMES"); + const std::string newDisabledPlugins = + existingDisabledPlugins.empty() + ? "usdSandboxProxy_plugin" + : existingDisabledPlugins + pluginNameSep + "usdSandboxProxy_plugin"; + _envVarOverrides.emplace_back("PXR_DISABLED_PLUGIN_NAMES", newDisabledPlugins); + + // Override OS-specific temp directories so that plugins only use temp directories that + // the sandbox has access to + +#if SANDBOX_IS_WINDOWS + // The sandboxed process has low integrity, which means it can only write to + // %USERPROFILE/AppData/LocalLow, so that will be our temp directory + const std::filesystem::path windowsTempDirPath = + std::filesystem::path(ArchGetEnv("USERPROFILE")) / "AppData" / "LocalLow"; + const std::string windowsTempDir = windowsTempDirPath.string(); + + // Windows can find a temp directory using either using TEMP or TMP environment variables + _envVarOverrides.emplace_back("TEMP", windowsTempDir); + _envVarOverrides.emplace_back("TMP", windowsTempDir); + +#elif SANDBOX_IS_MACOS + // The sandbox profile grants access to a specific temp directory (defined in + // hardening.h). MacOS finds a temp directory using the TMPDIR environment variable. + _envVarOverrides.emplace_back("TMPDIR", adobe::usd::sandbox::hardening::GetTempDirMacOS()); + +#endif + } +}; + +// This should be using PLUG_THIS_PLUGIN but it seems like our build system does not +// support it yet, so we use the registry to get the plugin path instead. + +static PlugPluginPtr sThisPlugin = + PlugRegistry::GetInstance().GetPluginWithName("usdSandboxProxy_plugin"); + +JsObject +getPluginData() +{ + if (sThisPlugin) { + JsObject pluginData = + sThisPlugin->GetMetadataForType(TfType::Find()); + return pluginData; + } + return {}; +} + +// Get a variable from the plugInfo.json file for the plugin. If it is not found or not a string, +// an empty string is returned. +std::string +getPlugInfoVar(const std::string& varName) +{ + std::string plugInfoVar = ""; + JsObject pluginData = getPluginData(); + if (pluginData.count(varName) > 0) { + JsValue ext = pluginData[varName]; + if (ext.Is()) { + plugInfoVar = ext.Get(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "PlugInfo variable read: %s = %s\n", + varName.c_str(), + plugInfoVar.c_str()); + } else { + TF_WARN("PlugInfo variable %s is not a string\n", varName.c_str()); + } + } + return plugInfoVar; +} + +// Gets the extensions from the plugin metadata. +// at runtime to avoid a build time dependency on what plugins are +// sandboxed +std::vector +getSandboxedExtensions() +{ + std::vector extensions; + JsObject pluginData = getPluginData(); + JsValue ext = pluginData["extensions"]; + if (ext.IsArrayOf()) { + extensions = ext.GetArrayOf(); + } + return extensions; +} + +/* + * Resolve a path that is specified relative to the plugin DLL's directory. + * + * pluginDllPath: the absolute path to the plugin library or its plugInfo.json; its + * parent directory is used as the base for resolution. + * relativeLocation: the path to resolve relative to that base directory. + * + * Returns the weakly-canonicalized absolute path. + */ +std::filesystem::path +composeAbsoluteSandboxPath(const std::filesystem::path& pluginDllPath, + const std::filesystem::path& relativeLocation) +{ + return std::filesystem::weakly_canonical(std::filesystem::path(pluginDllPath.parent_path()) / + relativeLocation); +} + +/* + * Resolve symlinks in an asset I/O path so the path the sandboxed worker opens matches the + * canonical path the sandbox profile grants access to. Falls back to the input path if resolution + * fails. + * + * path: the (possibly symlinked) absolute path the worker will read from or write to. + * + * Returns the symlink-resolved path, or path unchanged if resolution fails. + */ +std::string +canonicalizeSandboxIoPath(const std::string& path) +{ + std::error_code ec; // Non-throwing overload to not crash host process + std::filesystem::path canonical = + std::filesystem::weakly_canonical(std::filesystem::path(path), ec); + if (ec) { + // Falling back to the unresolved path may re-introduce a symlinked prefix that the sandbox + // profile does not grant, causing the worker's open to be denied. + TF_WARN("(HOST) Failed to canonicalize sandbox asset path \"%s\": %s. Falling back to the " + "unresolved path.", + path.c_str(), + ec.message().c_str()); + return path; + } + return canonical.string(); +} + +std::filesystem::path +getSandboxExecutablePath() +{ + std::filesystem::path sandboxExecutablePath = ""; + JsObject pluginData = getPluginData(); + JsValue ext = pluginData["SandboxExecutableRelativePath"]; + if (ext.Is()) { + std::string relativePath = ext.Get(); + sandboxExecutablePath = composeAbsoluteSandboxPath(sThisPlugin->GetPath(), relativePath); + } + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Found sandbox executable path from plugInfo: %s\n", + sandboxExecutablePath.string().c_str()); + return sandboxExecutablePath; +} + +std::filesystem::path +getProxyPluginPath() +{ + std::filesystem::path proxyPluginPath = ""; + JsObject pluginData = getPluginData(); + JsValue ext = pluginData["ProxyPluginPath"]; + if (ext.Is()) { + std::string path = ext.Get(); + proxyPluginPath = std::filesystem::path(path); + if (!proxyPluginPath.is_absolute()) { + proxyPluginPath = composeAbsoluteSandboxPath(sThisPlugin->GetPath(), path); + } + } + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Found current proxy plugin path from plugInfo: %s\n", + proxyPluginPath.string().c_str()); + return proxyPluginPath; +} + +std::filesystem::path +getUnsafePluginRoot() +{ + std::filesystem::path unsafePluginRoot = ""; + JsObject pluginData = getPluginData(); + JsValue ext = pluginData["UnsafePluginRelativePath"]; + if (ext.Is()) { + std::string relativePath = ext.Get(); + unsafePluginRoot = composeAbsoluteSandboxPath(sThisPlugin->GetPath(), relativePath); + } + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Found sandboxed plugins root from plugInfo: %s\n", + unsafePluginRoot.string().c_str()); + return unsafePluginRoot; +} + +} +UsdSandboxProxyFileFormat::UsdSandboxProxyFileFormat() + : SdfFileFormat(UsdSandboxProxyFileFormatTokens->Id, + UsdSandboxProxyFileFormatTokens->Version, + UsdSandboxProxyFileFormatTokens->Target, + // XXX This is coupled with what formats are sandboxed + // and we should ideally derive this from what is in the + // in pluginfo.json to have to inject this at build time + getSandboxedExtensions()) +{ + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "usdsandboxproxy %s\n", FILE_FORMATS_VERSION); + _sandboxExecutablePath = getSandboxExecutablePath(); + _proxyPluginPath = getProxyPluginPath(); + _unsafePluginRoot = getUnsafePluginRoot(); +} + +UsdSandboxProxyFileFormat::~UsdSandboxProxyFileFormat() {} + +void +UsdSandboxProxyFileFormat::ComposeFieldsForFileFormatArguments( + const std::string& assetPath, + const PcpDynamicFileFormatContext& context, + FileFormatArguments* args, + VtValue* dependencyContextData) const +{} + +bool +UsdSandboxProxyFileFormat::CanRead(const std::string& filePath) const +{ + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "CanRead: %s\n", filePath.c_str()); + return true; +} + +namespace { + +// Gather every item of an SdfListOp across all six of its list-op positions. A subverted worker +// may author a composition arc in any position -- including Deleted: a deleted-list arc composes +// to nothing, but a fail-closed gate rejects on any external arc regardless of position, so the +// enumeration must be complete. +template +std::vector +allListOpItems(const SdfListOp& op) +{ + std::vector items; + for (SdfListOpType type : { SdfListOpTypeExplicit, + SdfListOpTypeAdded, + SdfListOpTypeDeleted, + SdfListOpTypePrepended, + SdfListOpTypeAppended, + SdfListOpTypeOrdered }) { + const std::vector& part = op.GetItems(type); + items.insert(items.end(), part.begin(), part.end()); + } + return items; +} + +// True if the reference/payload list op holds any arc with a non-empty asset path (an *external* +// arc). An internal reference/payload (empty asset path) targets the same layer -- used for +// instancing -- and is allowed. +template +bool +hasExternalArc(const VtValue& fieldValue) +{ + if (!fieldValue.IsHolding()) { + return false; + } + for (const auto& arc : allListOpItems(fieldValue.UncheckedGet())) { + if (!arc.GetAssetPath().empty()) { + return true; + } + } + return false; +} + +// Keep the assetsPath export write inside assetsPath. The write below is `assetsPath / name`, and +// `name` derives from a worker-authored asset path that may be absolute or climb out via "..", so +// an unconstrained join is a path-traversal write of attacker-controlled bytes. Returns `name` +// unchanged when the resulting write stays within assetsPath -- including a clean nested name +// ("textures/img.png") or an absolute path that already lies inside assetsPath -- otherwise its +// bare filename, which is always contained. The test is on the normalized joined path (not on the +// name's shape), matching the std::filesystem semantics that perform the write. +std::string +containedAssetName(const std::string& assetsPath, const std::string& name) +{ + namespace fs = std::filesystem; + std::error_code ec; + const fs::path base = fs::absolute(assetsPath, ec).lexically_normal(); + const fs::path joined = fs::absolute(fs::path(assetsPath) / name, ec).lexically_normal(); + const fs::path rel = joined.lexically_relative(base); + const bool within = !rel.empty() && *rel.begin() != ".."; + if (within) { + return name; + } + // The delivered asset's worker-authored name escaped assetsPath; contain it to its basename. + // Log the event but not the name itself -- it is attacker-controlled and must stay out of logs. + TF_WARN("(HOST) Contained an escaping delivered-asset name to its basename on extraction " + "(traversing package key from the worker)."); + return fs::path(name).filename().string(); +} + +/* + * Scrub a worker-produced layer before it is composed into the caller's stage, turning the host + * from a confused deputy into an informed one. + * + * (1) Fail-closed structural gate: reject the whole import if the layer carries any external + * composition arc (reference, payload, sublayer, or value clips) or any variant set -- + * shapes a by-the-book sandboxed import never emits, so their presence signals a subverted + * worker. + * (2) Reference routing: make every asset value resolve only through the sandbox's own resolvers. + * InMemory:// and package-relative cache hits are kept (they route to InMemoryResolver / + * SandboxProxyResolver); everything else -- including a cache hit on a plain/absolute key, + * which would route to the host default resolver -- is quarantined into an inert BadAsset:// + * URI. + * + * Returns false (import fails) on a structural-gate hit; otherwise rewrites in place and returns + * true. + */ +static bool +scrubImportedLayer(const PXR_NS::SdfLayerRefPtr& tempLayer) +{ + using namespace adobe::usd::sandbox; + + static constexpr char kRejectFmt[] = + "(HOST) Rejecting sandbox output: %s -- sandboxed imports never emit these; treating worker " + "output as attacker-controlled."; + + // Sublayers are external composition arcs; check once at the layer level. + if (!tempLayer->GetSubLayerPaths().empty()) { + TF_CODING_ERROR(kRejectFmt, "sublayer"); + return false; + } + + // Structural gate: walk every prim spec for external arcs, value clips, and variant sets. + bool rejected = false; + std::string rejectReason; + tempLayer->Traverse(SdfPath::AbsoluteRootPath(), [&](const SdfPath& path) { + if (rejected || !path.IsPrimPath()) { + return; + } + if (hasExternalArc( + tempLayer->GetField(path, SdfFieldKeys->References))) { + rejected = true; + rejectReason = "external reference"; + } else if (hasExternalArc( + tempLayer->GetField(path, SdfFieldKeys->Payload))) { + rejected = true; + rejectReason = "payload"; + } else if (tempLayer->HasField(path, SdfFieldKeys->Clips) || + tempLayer->HasField(path, SdfFieldKeys->ClipSets)) { + rejected = true; + rejectReason = "value clips"; + } else if (SdfPrimSpecHandle prim = tempLayer->GetPrimAtPath(path); + prim && !prim->GetVariantSets().empty()) { + rejected = true; + rejectReason = "variant set"; + } + }); + if (rejected) { + TF_CODING_ERROR(kRejectFmt, rejectReason.c_str()); + return false; + } + + // Reference routing: keep sandbox-routed values, quarantine everything else. + SandboxAssetCache& cache = SandboxAssetCache::GetInstance(); + FindAndModifyAssetPaths(tempLayer, [&](const std::string& authoredPath, std::string& newName) { + if (IsQuarantined(authoredPath)) { + return false; // already inert -- never nest + } + if (TfStringStartsWith(authoredPath, "InMemory://")) { + return false; // routes to InMemoryResolver + } + // A package-relative reference with a cache hit routes to SandboxProxyResolver; its bytes + // crossed the boundary. A cache hit on a plain/absolute key does NOT qualify -- it would + // resolve through the host default resolver -- so it is quarantined below. + if (ArIsPackageRelativePath(authoredPath) && cache.FindCachedAsset(authoredPath)) { + return false; + } + newName = QuarantineReference(authoredPath); + // newName is the BadAsset:// form -- safe to log (no raw attacker bytes). + TF_WARN("(HOST) Quarantined unmarshalled reference (asset did not cross the sandbox " + "boundary): %s", + newName.c_str()); + return true; + }); + + return true; +} + +} // namespace + +bool +UsdSandboxProxyFileFormat::Read(PXR_NS::SdfLayer* layer, + const std::string& resolvedPath, + bool metadataOnly) const + +{ + using namespace adobe::usd::sandbox; + using namespace std::filesystem; + + TfStopwatch hostSetupWatch, waitForConversionWatch, importWatch, transferDataWatch; + + // Warning message provides a reliable indicator in logs that sandboxing is active regardless + // of whether debug prints are active or not (based on TF_DEBUG environment variable) + TF_WARN("(HOST) Using sandbox to read asset: %s\n", resolvedPath.c_str()); + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "Read: %s\n", resolvedPath.c_str()); + hostSetupWatch.Start(); + + HostProtocol protocol; + + // TODO: This will need to be refactored. This was initially setup for absolute pathing but + // all of these paths need to be relative for package builds. We need to either find these + // paths at runtime or we need to make sure relative paths are being set to SandboxLibraryPath + // at build time. Baked paths only work for local builds. This will prevent us from releasing + // standalone packages of file formats. + std::string sandboxAccessiblePaths = getPlugInfoVar("SandboxLibraryPath") + ":" + + getUnsafePluginRoot().string() + ":" + + getExecutableDirectory().parent_path().string(); + + // Resolve symlinks (e.g. /var -> /private/var on macOS) so the path the sandboxed worker + // reads matches the canonical path the sandbox profile grants access to. + const std::string canonicalizedPath = canonicalizeSandboxIoPath(resolvedPath); + + hardening::LaunchHardening hardeningArgs = hardening::BuildLaunchHardening( + { canonicalizedPath, sandboxAccessiblePaths, /*isExport=*/false }); + + std::string processName = _sandboxExecutablePath.string(); + TF_DEBUG_MSG( + FILE_FORMAT_SANDBOXPROXY, "(HOST) Creating process named %s\n", processName.c_str()); + + { + // This replaces the proxy plugin's entry in the PXR_PLUGINPATH_NAME environment + // variable with the sandboxed plugins entry, so the sandboxed process can find the unsafe + // plugins and not find the proxy plugin. This is reverted when the object is destructed, + // when it goes out of scope after the sandboxed process is launched with the necessary + // environment. + ScopedPluginPathOverride pluginPathScope(_proxyPluginPath.string(), + _unsafePluginRoot.string()); + + if (!protocol.LaunchProcess(processName, + canonicalizedPath, + _unsafePluginRoot.string(), + /*isExport=*/false, + hardeningArgs.preExecHook, + hardeningArgs.commandPrefix)) { + TF_WARN("(HOST) Failed to launch unsafe process."); + return false; + } else { + hostSetupWatch.Stop(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Launched unsafe Process. Init and Launch duration: %ld\n", + static_cast(hostSetupWatch.GetMilliseconds())); + } + } + + // Send and wait for conversion data + + // 1. Send file format arguments to sandboxed process so it can convert the asset + // 2. Wait for the sandboxed process to calculate and request a shared memory size + // 3. Initialize shared memory with the required size + // 4. Send the shared memory name and size to the sandboxed process + + // Retrieve FileFormatArguments from the layer and send to sandboxed process + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) Writing file format arguments to pipe\n"); + std::map fileFormatArgs = layer->GetFileFormatArguments(); + + // assetsPath is processed on the host, not sent to the sandbox. If set, it indicates where + // to write image data during import; the sandboxed process cannot output images directly. + std::string assetsPath = ""; + if (fileFormatArgs.find(kAssetsPathArg) != fileFormatArgs.end()) { + assetsPath = fileFormatArgs[kAssetsPathArg]; + fileFormatArgs.erase(kAssetsPathArg); + } + + // Host-side policy: lift the cap on the worker-reported asset size for legitimate large + // assets. Consumed here (and erased) so it is not forwarded to the sandboxed worker. + bool allowLargeAssets = + adobe::usd::sandbox::ConsumeBoolArg(fileFormatArgs, kAllowLargeAssetsArg); + + if (!protocol.SendFileFormatArgs(fileFormatArgs)) { + TF_WARN("(HOST) Failed to write file format arguments to sandbox."); + return false; + } + + // Wait for the sandboxed process to calculate and request a shared memory size + size_t assetsSize = 0; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Waiting for sandboxed process to request shared memory size\n"); + if (!protocol.ReceiveAssetSize(assetsSize, allowLargeAssets)) { + TF_WARN("(HOST) Failed to read asset size from sandbox."); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Received assets size: %zu. Initializing shared memory.\n", + assetsSize); + if (!protocol.InitializeSharedMemory(hardening::GetShmNamePrefix(), assetsSize)) { + TF_WARN("(HOST) Failed to communicate size and allocate shared memory."); + return false; + } + + // Wait for the worker to write the data and exit before this process reads it. + waitForConversionWatch.Start(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) Waiting for conversion process.\n"); + if (!protocol.WaitForCompletion()) { + TF_WARN("(HOST) Failed to wait for conversion process."); + return false; + } else { + waitForConversionWatch.Stop(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) File Converted. Duration: %ld\n", + static_cast(waitForConversionWatch.GetMilliseconds())); + } + + // Read the assets from the binary data and add them to the cache + AssetReader assetReader(protocol.GetSharedMemory()); + if (!assetReader.ProcessAssetsFromSharedMemory( + [](const std::string& path, const std::shared_ptr& asset) { + if (path == inMemoryURI) { + // This is the USDC data - copy it to InMemoryResolver + auto writableAsset = ArGetResolver().OpenAssetForWrite( + ArResolvedPath(path), ArResolver::WriteMode::Replace); + + // TODO: REMOVE THIS ADDITIONAL COPY SOMEHOW!! + // Do we have to read the ArAsset into a writeable asset? Can we just add the + // ArAsset directly into the InMemoryResolver? We could use + // InMemoryResolver::SetData to set the data directly, but then we need to cast + // the resolver. What are the best alternatives? + if (writableAsset) { + // Read all data from the source asset + size_t size = asset->GetSize(); + std::vector buffer(size); + asset->Read(buffer.data(), size, 0); + + // Write to InMemoryResolver + writableAsset->Write(buffer.data(), size, 0); + + // TODO: Currently, inMemoryWritableAsset::Close always returns false, so this + // warning is emitted without being helpful. Uncomment this when that function + // properly returns true or false. + // if (!writableAsset->Close()) { + // TF_WARN( + // "(HOST) Failed to close USD writable asset while extracting \"%s\" + // from " "shared memory.", path.c_str()); + // } + } else { + TF_WARN("(HOST) Failed to open USD writable asset for extracting \"%s\" from " + "shared memory.", + path.c_str()); + } + } else { + // Texture assets - add to cache + SandboxAssetCache& sandboxAssetCache = SandboxAssetCache::GetInstance(); + sandboxAssetCache.AddImageToCache(path, asset); + } + })) { // if (!ProcessAssetsFromSharedMemory) + TF_WARN("(HOST) Error reading assets from shared memory."); + return false; + } + + importWatch.Start(); + SdfLayerRefPtr tempLayer = SdfLayer::FindOrOpen(inMemoryURI); + if (!tempLayer) { + TF_CODING_ERROR( + "(HOST) Failed to load USD data with specified InMemory URI: %s. This error should have " + "been caught earlier, when the asset was created in the sandbox.\n", + inMemoryURI.c_str()); + return false; + } + importWatch.Stop(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Converted File Imported. Duration: %ld\n", + static_cast(importWatch.GetMilliseconds())); + + // Scrub the worker-produced layer before it is composed into the caller's stage: fail closed + // on external composition arcs / variant sets, and route or quarantine every asset reference + // so nothing resolves through the host's privileged resolver stack (confused-deputy fix). On + // failure nothing is transferred into `layer`, so the caller sees a clean import failure. + if (!scrubImportedLayer(tempLayer)) { + return false; + } + + if (!assetsPath.empty()) { + std::function createAssetPath = + [&](const std::string& authoredPath, std::string& newName) { + // A quarantined reference is inert and must never be resolved or written; the scrub + // pass already neutralized it. (It would also be a cache miss below, but guard the + // invariant explicitly against future edits.) + if (IsQuarantined(authoredPath)) { + return false; + } + // Update the asset reference to be the packaged asset name + auto [outerPath, innerPath] = ArSplitPackageRelativePathInner(authoredPath); + newName = !innerPath.empty() ? innerPath : authoredPath; + + // Contain the write within assetsPath. The name comes from a worker-authored path and + // may be absolute or traverse via ".."; without this a subverted worker could write + // its (attacker-controlled) bytes outside the caller's assetsPath. Names already + // inside assetsPath are untouched; escaping names collapse to their basename. newName + // also becomes the rewritten reference, so the reference and the file stay in sync. + newName = containedAssetName(assetsPath, newName); + + SandboxAssetCache& sandboxAssetCache = SandboxAssetCache::GetInstance(); + if (std::shared_ptr asset = + sandboxAssetCache.FindCachedAsset(authoredPath)) { + // There may be multiple references to the same asset in the USD data, but we + // don't want to write out the image each time. If we remove the image from the + // cache, later iterations on the same asset won't find it and we won't write out + // the image + sandboxAssetCache.RemoveImageFromCache(authoredPath); + + // Create the assetsPath directory if it hasn't been created yet. This will only + // create the directory hierarchy if it doesn't exist, so it will only run once + if (!TfMakeDirs(assetsPath, -1, true)) { + TF_RUNTIME_ERROR("Failed to create directory for assetsPath: %s. Not using " + "assetsPath for asset: %s", + assetsPath.c_str(), + authoredPath.c_str()); + return false; + } + + std::filesystem::path filepath = std::filesystem::path(assetsPath) / newName; + writeDataToDisk(filepath, asset->GetBuffer().get(), asset->GetSize()); + } + return true; + }; + + // Iterate over all assets in the USD data, resolve and export all referenced assets, and + // update each reference to use the new resolved name instead of a packaged path + FindAndModifyAssetPaths(tempLayer, createAssetPath); + } + + transferDataWatch.Start(); + layer->TransferContent(tempLayer); + transferDataWatch.Stop(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Transfer data time: %ld\n", + static_cast(transferDataWatch.GetMilliseconds())); + + return true; +} + +bool +UsdSandboxProxyFileFormat::ReadFromString(SdfLayer* layer, const std::string& str) const +{ + // TODO: Implement reading from string. + + return false; +} + +bool +UsdSandboxProxyFileFormat::WriteToFile(const SdfLayer& layer, + const std::string& filename, + const std::string& comment, + const FileFormatArguments& args) const +{ + using namespace adobe::usd::sandbox; + using namespace std::filesystem; + + TfStopwatch hostSetupWatch, waitForConversionWatch; + + // Normalize the path so the sandbox profile and the actual file write use the same + // canonical form. Without this, `./` or `..` components cause macOS sandbox + // `(subpath ...)` rules to not match the kernel-normalized paths. + // Relative paths must also be made absolute so the sandboxed process (which may + // have a different CWD) resolves the same file. + std::filesystem::path filePath(filename); + std::string absoluteFilename = + (filePath.is_relative() ? std::filesystem::absolute(filePath) : filePath) + .lexically_normal() + .string(); + + // Warning message provides a reliable indicator in logs that sandboxing is active regardless + // of whether debug prints are active or not (based on TF_DEBUG environment variable) + TF_WARN("(HOST) Using sandbox to write asset: %s\n", absoluteFilename.c_str()); + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "Write: %s\n", absoluteFilename.c_str()); + + // Ensure the output directory exists before launching the sandbox, since the + // sandboxed process only has write permission to this directory, not its parent. + std::filesystem::path outputDir = std::filesystem::path(absoluteFilename).parent_path(); + std::error_code ec; + std::filesystem::create_directories(outputDir, ec); + if (ec) { + TF_WARN("(HOST) Failed to create output directory %s: %s", + outputDir.string().c_str(), + ec.message().c_str()); + return false; + } + + // Now that the output directory exists, resolve symlinks so the path the sandboxed worker + // writes matches the canonical path the sandbox profile grants write access to. Without this, + // the worker attempts to open the unresolved path and the kernel's symlink resolution is + // denied by the profile, preventing the export file from being created. + absoluteFilename = canonicalizeSandboxIoPath(absoluteFilename); + + hostSetupWatch.Start(); + + // TODO: This will need to be refactored. This was initially setup for absolute pathing but + // all of these paths need to be relative for package builds. We need to either find these + // paths at runtime or we need to make sure relative paths are being set to SandboxLibraryPath + // at build time. Baked paths only work for local builds. This will prevent us from releasing + // standalone packages of file formats. + std::string sandboxAccessiblePaths = getPlugInfoVar("SandboxLibraryPath") + ":" + + getUnsafePluginRoot().string() + ":" + + getExecutableDirectory().parent_path().string(); + + std::string processName = _sandboxExecutablePath.string(); + + // Load the USD before launching the process + + // The usdc data must be modified by changing asset paths to use the InMemory:// scheme, so + // our resolvers can find the assets. We create a new anonymous layer to modify, since we + // can't modify the const layer parameter. + SdfLayerRefPtr modifiedLayer = SdfLayer::CreateAnonymous(".usdc"); + SdfLayerHandle layerHandle = SdfLayer::Find(layer.GetIdentifier()); + if (!layerHandle) { + TF_WARN("(HOST) Failed to find layer handle for %s", layer.GetIdentifier().c_str()); + return false; + } + modifiedLayer->TransferContent(layerHandle); + + // Lambda to modify USD asset names to have InMemory:// prefix + std::function createInMemoryAssetName = + [](const std::string& authoredPath, std::string& newName) { + // Update the asset reference to be the packaged asset name + auto [outerPath, innerPath] = ArSplitPackageRelativePathInner(authoredPath); + newName = "InMemory://" + (!innerPath.empty() ? innerPath : authoredPath); + + return true; + }; + + // Normally, FindAndModifyAssetPaths will resolve assets using the layer that is modified. + // In this case, this layer is an anonymous layer with no associated path. (Created so it can + // be modified with the necessary asset references). Because of this, it will not be able to + // properly resolve assets. Instead, we must pass in the original layer as well, which will be + // used to find and resolve referenced assets. + + // Get the asset paths that will be resolved, and modify them to use the InMemory:// scheme + std::unordered_map assets = + FindAndModifyAssetPaths(modifiedLayer, createInMemoryAssetName, layer); + assets.insert({ inMemoryURI, inMemoryURI }); + + // Convert modified layer to an ArAsset + ArGetResolver().OpenAssetForWrite(ArResolvedPath(inMemoryURI), ArResolver::WriteMode::Update); + modifiedLayer->Export(inMemoryURI); + + // Get the ArAssets themselves and write them to shared memory + AssetMap arAssets = GetArAssets(assets); + + HostProtocol protocol; + + hardening::LaunchHardening hardeningArgs = hardening::BuildLaunchHardening( + { absoluteFilename, sandboxAccessiblePaths, /*isExport=*/true }); + + // Build the asset writer against the protocol's shared memory (payload written after Create). + AssetWriter assetWriter(protocol.GetSharedMemory(), arAssets); + size_t assetsSize = assetWriter.GetSize(); + if (assetsSize == 0) { + TF_WARN("(HOST) Failed to set assets to write and get size in bytes"); + return false; + } + + // Launch the sandboxed process + + TF_DEBUG_MSG( + FILE_FORMAT_SANDBOXPROXY, "(HOST) Creating process named %s\n", processName.c_str()); + + { + // This replaces the proxy plugin's entry in the PXR_PLUGINPATH_NAME environment + // variable with the sandboxed plugins entry, so the sandboxed process can find the unsafe + // plugins and not find the proxy plugin. This is reverted when the object is destructed, + // when it goes out of scope after the sandboxed process is launched with the necessary + // environment. + ScopedPluginPathOverride pluginPathScope(_proxyPluginPath.string(), + _unsafePluginRoot.string()); + if (!protocol.LaunchProcess(processName, + absoluteFilename, + _unsafePluginRoot.string(), + /*isExport=*/true, + hardeningArgs.preExecHook, + hardeningArgs.commandPrefix)) { + TF_WARN("(HOST) Failed to launch unsafe process."); + return false; + } else { + hostSetupWatch.Stop(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Launched unsafe Process. Init and Launch duration: %ld\n", + static_cast(hostSetupWatch.GetMilliseconds())); + } + } + + // Export fileformat args flow to the worker. + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) Sending fileformat args to sandboxed process\n"); + if (!protocol.SendFileFormatArgs(args)) { + TF_WARN("(HOST) Failed to write file format arguments to sandbox."); + return false; + } + + // Export: create -> write payload -> announce. The worker blocks on + // ReceiveAndConnectSharedMemory until AnnounceSharedMemory, so it cannot read partial data + TF_DEBUG_MSG( + FILE_FORMAT_SANDBOXPROXY, "(HOST) Creating shared memory with size %zu.\n", assetsSize); + if (!protocol.CreateSharedMemory(hardening::GetShmNamePrefix(), assetsSize)) { + TF_WARN("(HOST) Failed to allocate shared memory."); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) Writing assets to shared memory\n"); + if (!assetWriter.WriteAssetsToSharedMemory()) { + TF_WARN("(HOST) Failed to write assets for layer: %s", absoluteFilename.c_str()); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) Announcing shared memory to subprocess\n"); + if (!protocol.AnnounceSharedMemory()) { + TF_WARN("(HOST) Failed to send shared memory info to sandbox."); + return false; + } + + // The sandboxed process can now convert the asset + + waitForConversionWatch.Start(); + if (!protocol.WaitForCompletion()) { + TF_WARN("(HOST) Error waiting for conversion process."); + return false; + } else { + waitForConversionWatch.Stop(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) File Converted. Duration: %ld\n", + static_cast(waitForConversionWatch.GetMilliseconds())); + } + + return true; +} + +bool +UsdSandboxProxyFileFormat::WriteToString(const SdfLayer& layer, + std::string* str, + const std::string& comment) const +{ + // Implement writing to string + return false; +} + +bool +UsdSandboxProxyFileFormat::WriteToStream(const SdfSpecHandle& spec, + std::ostream& out, + size_t indent) const +{ + out << "WriteToStream: Nothing to see." << std::endl; + return true; +} + +PXR_NAMESPACE_CLOSE_SCOPE diff --git a/sandbox/src/fileformat/plugInfo.json.in b/sandbox/src/fileformat/plugInfo.json.in new file mode 100644 index 00000000..abd222a1 --- /dev/null +++ b/sandbox/src/fileformat/plugInfo.json.in @@ -0,0 +1,31 @@ +{ + "Plugins": [ + { + "Info": { + "Types": { + "UsdSandboxProxyFileFormat": { + "bases": ["SdfFileFormat"], + "displayName": "Fileformat Sandbox proxy", + "extensions": [@PLUG_INFO_SANDBOXED_EXTENSIONS_CASE_INSENSITIVE@], + "formatId": "sandbox", + "primary": true, + "SandboxExecutableRelativePath": "@PLUG_INFO_SANDBOX_EXECUTABLE_RELATIVE_PATH@", + "ProxyPluginPath": "@PLUG_INFO_PROXY_PLUGIN_PATH@", + "UnsafePluginRelativePath": "@PLUGIN_INFO_UNSAFE_PLUGIN_RELATIVE_PATH@", + "target": "usd", + "SandboxLibraryPath": "@PLUG_INFO_SANDBOX_LIBRARY_PATH@" + }, + "adobe::usd::sandbox::SandboxProxyResolver" : { + "bases": [ "ArPackageResolver" ], + "extensions": [@PLUG_INFO_SANDBOXED_EXTENSIONS_CASE_SENSITIVE@] + } + } + }, + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", + "Name": "usdSandboxProxy_plugin", + "ResourcePath": "resources", + "Root": "..", + "Type": "library" + } + ] +} diff --git a/sandbox/src/fileformat/plugInfo.root.json b/sandbox/src/fileformat/plugInfo.root.json new file mode 100644 index 00000000..2e20f3d6 --- /dev/null +++ b/sandbox/src/fileformat/plugInfo.root.json @@ -0,0 +1,3 @@ +{ + "Includes": [ "*/resources/" ] +} diff --git a/sandbox/src/fileformat/sandboxProxyResolver.cpp b/sandbox/src/fileformat/sandboxProxyResolver.cpp new file mode 100644 index 00000000..36b37616 --- /dev/null +++ b/sandbox/src/fileformat/sandboxProxyResolver.cpp @@ -0,0 +1,70 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include +#include + +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE; +namespace adobe::usd::sandbox { + +AR_DEFINE_PACKAGE_RESOLVER(adobe::usd::sandbox::SandboxProxyResolver, ArPackageResolver); + +SandboxProxyResolver::SandboxProxyResolver() {} + +std::string +SandboxProxyResolver::Resolve(const std::string& resolvedPackagePath, + const std::string& packagedPath) +{ + std::string joinedAssetPath = ArJoinPackageRelativePath(resolvedPackagePath, packagedPath); + SandboxAssetCache& sandboxAssetCache = SandboxAssetCache::GetInstance(); + std::shared_ptr asset = sandboxAssetCache.FindCachedAsset(joinedAssetPath); + // Use the joined path to verify if the asset exists. If it does, only the packagedPath is + // needed. If it doesn't exist, return the empty string + return asset ? packagedPath : ""; +} + +std::shared_ptr +SandboxProxyResolver::OpenAsset(const std::string& resolvedPackagePath, + const std::string& resolvedPackagedPath) +{ + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "OpenAsset: %s %s\n", + resolvedPackagePath.c_str(), + resolvedPackagedPath.c_str()); + std::string assetPath = ArJoinPackageRelativePath(resolvedPackagePath, resolvedPackagedPath); + SandboxAssetCache& sandboxAssetCache = SandboxAssetCache::GetInstance(); + std::shared_ptr asset = sandboxAssetCache.FindCachedAsset(assetPath); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "SandboxProxyResolver::OpenAsset: %s asset in cache for uri: %s\n", + asset ? "Successfully found" : "ERROR: Failed to find", + assetPath.c_str()); + return asset; +} + +void +SandboxProxyResolver::BeginCacheScope(PXR_NS::VtValue* data) +{ + // TODO: Add cache functionality here +} + +void +SandboxProxyResolver::EndCacheScope(PXR_NS::VtValue* data) +{ + // TODO: Add cache functionality here +} +} diff --git a/sandbox/src/hardening/CMakeLists.txt b/sandbox/src/hardening/CMakeLists.txt new file mode 100644 index 00000000..b68950bf --- /dev/null +++ b/sandbox/src/hardening/CMakeLists.txt @@ -0,0 +1,58 @@ +add_library(sandboxHardening SHARED) + +usd_plugin_compile_config(sandboxHardening) +target_compile_definitions(sandboxHardening PRIVATE USDSANDBOX_EXPORTS) + +if(WIN32) + target_sources(sandboxHardening PRIVATE "hardeningWin.cpp") +elseif(APPLE) + target_sources(sandboxHardening PRIVATE "hardeningMac.cpp" "sandboxProfile.cpp") +elseif(UNIX) + target_sources(sandboxHardening PRIVATE "hardeningLinux.cpp") + + include(ExternalProject) + ExternalProject_Add( + libseccomp + PREFIX ${CMAKE_BINARY_DIR}/libseccomp + GIT_REPOSITORY "https://github.com/seccomp/libseccomp.git" + GIT_TAG "v2.6.0" + CONFIGURE_COMMAND sh -c "cd ${CMAKE_BINARY_DIR}/libseccomp/src/libseccomp && ./autogen.sh && ./configure && echo CONFIGURE SUCCESS" + BUILD_COMMAND sh -c "cd ${CMAKE_BINARY_DIR}/libseccomp/src/libseccomp && make -j$(nproc) VERBOSE=1 && echo BUILD SUCCESS" + INSTALL_COMMAND sh -c "cd ${CMAKE_BINARY_DIR}/libseccomp/src/libseccomp && echo INSTALL SUCCESS" + LOG_CONFIGURE 1 + LOG_BUILD 1 + LOG_INSTALL 1 + LOG_OUTPUT_ON_FAILURE 1 + ) + install(DIRECTORY ${CMAKE_BINARY_DIR}/libseccomp/src/libseccomp/src/.libs DESTINATION libseccomp) +endif() + +target_include_directories(sandboxHardening +PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/../../include/" +) + +target_link_libraries(sandboxHardening +PUBLIC + usdIpc +PRIVATE + usd +) + +if(APPLE) + # sandboxProfile.cpp uses getExecutableDirectory() from sandboxUtils (utilities.h). + target_link_libraries(sandboxHardening PRIVATE sandboxUtils) +endif() + +if(UNIX AND NOT APPLE) + add_dependencies(sandboxHardening libseccomp) + target_include_directories(sandboxHardening + PRIVATE ${CMAKE_BINARY_DIR}/libseccomp/src/libseccomp/include) + target_link_directories(sandboxHardening + PRIVATE ${CMAKE_BINARY_DIR}/libseccomp/src/libseccomp/src/.libs) + target_link_libraries(sandboxHardening PRIVATE seccomp rt) +endif() + +set_target_properties(sandboxHardening PROPERTIES INSTALL_RPATH "${CMAKE_INSTALL_RPATH}") + +install(TARGETS sandboxHardening) diff --git a/sandbox/src/hardening/hardeningLinux.cpp b/sandbox/src/hardening/hardeningLinux.cpp new file mode 100644 index 00000000..e7344ac3 --- /dev/null +++ b/sandbox/src/hardening/hardeningLinux.cpp @@ -0,0 +1,233 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#if SANDBOX_IS_LINUX + +#include + +#include + +// Linux headers needed for namespace hardening +#include +#include +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::sandbox::hardening { + +// Async-signal-safe calls are required (see +// https://man7.org/linux/man-pages/man7/signal-safety.7.html). After fork() in a multithreaded +// process, a lock held by another thread at fork() (malloc's arena, the stdio stream lock, locale +// state, etc...) stays locked forever in the child, so only async-signal-safe calls are safe until +// exec(). For that reason, all functions called by the preExecHook must be async-signal-safe. + +// async-signal-safe: +// Writes msg to stderr with a single write(). fprintf/strerror can deadlock, while write() is a +// bare syscall with no such lock. Messages are fixed strings naming the failing step; errno +// detail is omitted (formatting it safely is not worth it for a should-never-happen failure, and +// the parent sees the hook fail anyway). +static void +writeStderrRaw(const char* msg) +{ + size_t len = 0; + while (msg[len] != '\0') { + ++len; + } + (void)!write(STDERR_FILENO, msg, len); +} + +// Signal handler, run when the child process attempts to execute a syscall forbidden by seccomp +static void +sigsys_handler(int /*signo*/, siginfo_t* /*info*/, void* /*context*/) +{ + // Signal handler: only async-signal-safe calls here (no Logger / TF_* / buffered stdio). + writeStderrRaw("hardening: a syscall blocked by the seccomp policy (SIGSYS); terminating.\n"); + _exit(1); +} + +// async-signal-safe: +// Writes `len` bytes of `data` to the /proc file at `path` (open/write/close). Returns false only +// if the file cannot be OPENED (fatal for the caller); a failed WRITE is logged but tolerated +// (returns true). +// +// Callers should use this for the child's user-namespace ID-mapping files (/proc/self/setgroups, +// uid_map, gid_map), whose writes some containers / CI deny with EPERM for unprivileged user +// namespaces. Tolerating that keeps the sandbox usable there: the process then runs with no ID +// mapping (the unmapped "overflow" user), still confined by seccomp and the mount/network +// namespaces. +static bool +writeProcFile(const char* path, const char* data, size_t len) +{ + int fd = open(path, O_WRONLY); + if (fd == -1) { + writeStderrRaw("sandbox: post-fork hardening failed to open a /proc mapping file; " + "sandboxed process will not start\n"); + return false; + } + if (write(fd, data, len) != static_cast(len)) { + writeStderrRaw("sandbox: post-fork hardening could not write a /proc id-map file; " + "continuing without user/group ID mapping\n"); + } + close(fd); + return true; +} + +// async-signal-safe: +// Format a string as expected for a /proc uid_map / gid_map line as such: +// "0 1\n" +// +// Writes into a given buffer (which must hold at least 32 bytes) and returns the number of bytes +// written. The decimal encoding is manually written here because snprintf is not +// async-signal-safe; a fixed 32-byte buffer always fits "0 " + a 64-bit decimal (<= 20 digits) + +// " 1\n", so there is no truncation to check. +static size_t +formatIdMapLine(char* buf, unsigned long id) +{ + size_t pos = 0; + buf[pos++] = '0'; + buf[pos++] = ' '; + + char digits[20]; // fits any 64-bit unsigned value + size_t n = 0; + do { + digits[n++] = static_cast('0' + (id % 10)); + id /= 10; + } while (id != 0); + while (n > 0) { + buf[pos++] = digits[--n]; + } + + buf[pos++] = ' '; + buf[pos++] = '1'; + buf[pos++] = '\n'; + return pos; +} + +LaunchHardening +BuildLaunchHardening(const LaunchHardeningArgs& /*args*/) +{ + LaunchHardening result; + result.commandPrefix = ""; + + // The pre-exec hook runs in the forked child after fork() but before exec(). Only + // async-signal-safe calls are permitted here. open/close/write/getuid/getgid/_exit are on the + // POSIX list; unshare/prctl are Linux syscall wrappers that take no userspace lock; id + // formatting is done here (formatIdMapLine) to avoid snprintf, which is not on the list + result.preExecHook = []() -> bool { + // Enter a new user, mount, and network namespace. + if (unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET) == -1) { + writeStderrRaw("sandbox: post-fork hardening failed to unshare namespaces; " + "sandboxed process will not start\n"); + return false; + } + + // Disable setgroups before writing gid_map (required by the kernel when running + // unprivileged user namespaces). + if (!writeProcFile("/proc/self/setgroups", "deny", 4)) { + return false; + } + + // Map user/group IDs: 0 inside == real UID/GID outside, formatted into a stack buffer with + // an async-signal-safe encoder + char idMap[32]; + size_t len = formatIdMapLine(idMap, static_cast(getuid())); + if (!writeProcFile("/proc/self/uid_map", idMap, len)) { + return false; + } + + len = formatIdMapLine(idMap, static_cast(getgid())); + if (!writeProcFile("/proc/self/gid_map", idMap, len)) { + return false; + } + + // Prevent the child from ever regaining privileges. + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) { + writeStderrRaw("sandbox: post-fork hardening failed to set no-new-privileges; " + "sandboxed process will not start\n"); + return false; + } + + return true; + }; + + return result; +} + +std::string +GetShmNamePrefix() +{ + return "/UsdSHM_"; +} + +bool +ApplyProcessRestrictions(bool /*isExport*/) +{ + // Install a SIGSYS handler so blocked syscalls are logged before the process exits. + struct sigaction act = {}; + act.sa_sigaction = sigsys_handler; + act.sa_flags = SA_SIGINFO; + sigaction(SIGSYS, &act, nullptr); + + scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW); + if (!ctx) { + TF_RUNTIME_ERROR("hardening: Failed to initialize seccomp context: %s", strerror(errno)); + return false; + } + + // Block critical system calls. seccomp_rule_add returns 0 on success or a negative errno if a + // rule cannot be added (e.g. an action or syscall unsupported by this kernel/libseccomp). + // Accumulate every result and fail below if any rule was dropped + int result = 0; + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execve), 0); + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(fork), 0); + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(vfork), 0); + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(ptrace), 0); + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(kill), 0); // and tkill, tgkill + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(setuid), 0); // and setgid, etc. + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(capset), 0); // and capget + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(mount), 0); // and umount, pivot_root + result |= seccomp_rule_add( + ctx, SCMP_ACT_KILL, SCMP_SYS(init_module), 0); // and delete_module, finit_module + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(ioperm), 0); // and iopl + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(reboot), 0); + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(open_by_handle_at), 0); + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL_PROCESS, SCMP_SYS(socket), 0); + result |= seccomp_rule_add(ctx, SCMP_ACT_KILL_PROCESS, SCMP_SYS(connect), 0); + + if (result != 0) { + TF_RUNTIME_ERROR("hardening: Failed to add one or more seccomp rules; refusing to run " + "without a complete syscall filter"); + seccomp_release(ctx); + return false; + } + + if (seccomp_load(ctx) < 0) { + TF_RUNTIME_ERROR("hardening: Failed to load seccomp rules: %s", strerror(errno)); + seccomp_release(ctx); + return false; + } + + seccomp_release(ctx); // Cleanup + return true; +} + +} // namespace adobe::usd::sandbox::hardening + +#endif // SANDBOX_IS_LINUX diff --git a/sandbox/src/hardening/hardeningMac.cpp b/sandbox/src/hardening/hardeningMac.cpp new file mode 100644 index 00000000..0c59de46 --- /dev/null +++ b/sandbox/src/hardening/hardeningMac.cpp @@ -0,0 +1,78 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#if SANDBOX_IS_MACOS + +#include + +#include "sandboxProfile.h" + +#include + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::sandbox::hardening { + +/* Build the macOS sandbox-exec command prefix from args. Canonicalizes the asset path + * to resolve symlinks, then generates the sandbox profile string. */ +LaunchHardening +BuildLaunchHardening(const LaunchHardeningArgs& args) +{ + // Canonicalize so the sandboxed path contains no symlinks. Each symlink + // the kernel resolves during path traversal needs its own file-read-metadata + // allow in the profile. For example, /var/folders/X/file would otherwise + // require metadata-read on /var (/var -> /private/var on macOS). + std::error_code ec; // Non-throwing overload to not crash host process + std::filesystem::path canonical = + std::filesystem::weakly_canonical(std::filesystem::path(args.resolvedPath), ec); + std::string canonicalPath; + if (ec) { + TF_WARN("(HOST) Failed to canonicalize path \"%s\" for executing the sandboxed process. " + "Falling back to original path: %s", + args.resolvedPath.c_str(), + ec.message().c_str()); + canonicalPath = args.resolvedPath; + } else { + canonicalPath = canonical.string(); + } + + std::string profile = adobe::usd::sandbox::GenerateSandboxProfile( + std::filesystem::path(canonicalPath), args.sandboxAccessiblePaths, args.isExport); + // Absolute path (not the bare "sandbox-exec") so the launcher can exec it without a PATH + // search, for async signal safety: see ProcessPosix::Launch. sandbox-exec ships at /usr/bin + // on every macOS. + std::string commandPrefix = "/usr/bin/sandbox-exec -p \"" + profile + "\""; + + return { commandPrefix, nullptr }; +} + +// macOS no-op: process restrictions are applied at launch time via sandbox-exec. +bool +ApplyProcessRestrictions(bool /*isExport*/) +{ + return true; // macOS: no-op (hardening applied at launch via sandbox-exec) +} + +// Return the shared-memory name prefix for macOS: a path under /private/tmp. +std::string +GetShmNamePrefix() +{ + return (GetTempDirMacOS() / "UsdSHM_").string(); +} + +} // namespace adobe::usd::sandbox::hardening + +#endif // SANDBOX_IS_MACOS diff --git a/sandbox/src/hardening/hardeningWin.cpp b/sandbox/src/hardening/hardeningWin.cpp new file mode 100644 index 00000000..2e0c6a02 --- /dev/null +++ b/sandbox/src/hardening/hardeningWin.cpp @@ -0,0 +1,99 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#if SANDBOX_IS_WINDOWS + +#include + +#include + +// windows.h must precede sddl.h: sddl.h's declarations (e.g. ConvertStringSidToSid) are gated +// behind SDK-version macros that windows.h establishes first. The blank line keeps them in +// separate include blocks so clang-format does not reorder them alphabetically. +#include + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::sandbox::hardening { + +/* Windows launch hardening is a no-op: restrictions are applied worker-side via + * ApplyProcessRestrictions(), not at launch time. */ +LaunchHardening +BuildLaunchHardening(const LaunchHardeningArgs& /*args*/) +{ + return {}; +} + +// Return the shared-memory name prefix for Windows: a Local namespace object name. +std::string +GetShmNamePrefix() +{ + return "Local\\UsdSharedMemory_"; +} + +/* Apply Windows worker-side restrictions. Import: lower the process integrity to Low. + * Export: currently a no-op (sandboxed export on Windows is not yet implemented). */ +bool +ApplyProcessRestrictions(bool isExport) +{ + if (isExport) { + // TODO: Add support for export with the sandbox. This can be done by writing to a temp + // output folder that low permissions allows, and then having the host process copy all + // files to the target location + TF_WARN("Export is not currently sandboxed on Windows!\n"); + return true; + } + + HANDLE hToken = NULL; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_DEFAULT | TOKEN_QUERY, &hToken)) { + TF_RUNTIME_ERROR("OpenProcessToken failed in ApplyProcessRestrictions. Error: %lu", + GetLastError()); + return false; + } + + // Convert string SID to PSID (Low Integrity SID: S-1-16-4096) + PSID pLowIntegritySid = NULL; + if (!ConvertStringSidToSid("S-1-16-4096", &pLowIntegritySid)) { + TF_RUNTIME_ERROR("ConvertStringSidToSid failed in ApplyProcessRestrictions. Error: %lu", + GetLastError()); + CloseHandle(hToken); + return false; + } + + TOKEN_MANDATORY_LABEL TIL = { 0 }; + TIL.Label.Attributes = SE_GROUP_INTEGRITY; + TIL.Label.Sid = pLowIntegritySid; + + if (!SetTokenInformation(hToken, + TokenIntegrityLevel, + &TIL, + sizeof(TOKEN_MANDATORY_LABEL) + GetLengthSid(pLowIntegritySid))) { + TF_RUNTIME_ERROR("SetTokenInformation failed in ApplyProcessRestrictions. Error: %lu", + GetLastError()); + LocalFree(pLowIntegritySid); + CloseHandle(hToken); + return false; + } + + // Clean up + LocalFree(pLowIntegritySid); + CloseHandle(hToken); + return true; +} + +} // namespace adobe::usd::sandbox::hardening + +#endif // SANDBOX_IS_WINDOWS diff --git a/sandbox/src/hardening/sandboxProfile.cpp b/sandbox/src/hardening/sandboxProfile.cpp new file mode 100644 index 00000000..a4db749a --- /dev/null +++ b/sandbox/src/hardening/sandboxProfile.cpp @@ -0,0 +1,252 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#if SANDBOX_IS_MACOS + +#include "sandboxProfile.h" + +#include + +#include +#include + +#include +#include + +using namespace PXR_NS; + +namespace adobe::usd::sandbox { + +/* +The sandbox profile is a string that is used to sandbox the process. It is a string literal that +is used in a sandbox-exec command on MacOS to allow the sandbox to access a temporary directory, +the directory to read or write the asset, and the libraries needed to run the process. + +This string uses unresolved placeholders to be filled in with information from the plug info file, +that may vary based on the build environment. The following placeholders are used for reading in: + +@LIBRARY_PATHS@: The library paths required to run the plugins +@SOURCE_DIR@: The source directory where the asset will be read from. On export, this can be blank +@EXPORT_DIR@: The export directory where the asset will be written. On import, this can be blank +@TEMP_DIR@: A temporary dir on MacOS that the sandbox can access for scratch work + +Each of these must be replaced by a rule or a list of rules, where each rule is: + (subpath \\\"PATH\\\") +When copied to the command line, it will be escaped as such: + (subpath \"PATH\") + +The allowed path must be an absolute path, and must be normalized to remove any "..". + +LIBRARY_PATHS may be a single path or a list of paths, depending on if all the libraries are in a +single directory, or scattered across multiple directories (likely to occur if being found with +LD_LIBRARY_PATH). If it is multiple paths, each must have its own rule. + +Note: since this string is being used in a sandbox-exec command, it must be escaped properly. So +even though it's a string literal, each double quote must be escaped with a backslash. +*/ +const std::string sandboxProfileTemplate = R"((version 1) +(deny default) +(debug allow) + +; Allow process execution, execvp else error 71 is thrown +(allow process-exec) +(allow sysctl-read) +(allow ipc-posix-shm-read*) +(allow ipc-posix-shm-write*) + +(allow file-read* + (literal \"/\") +) + +(allow file-read-metadata + (subpath \"/Users\") +) + +; Allow read and write access to the current working directory +(allow file-read* +@SOURCE_DIR@ +@TEMP_DIR@ +) + +(allow file-write* +@EXPORT_DIR@ +@TEMP_DIR@ +) + +; Allow paths to required libraries +(allow file-read* +@LIBRARY_PATHS@ +) + +; Allow sandbox to access internationalization +(allow file-read* + (subpath \"/usr/share/i18n\") +) + +)"; + +// Internal substitution helper functions + +/* + * Create a sandbox profile path from a string. If the path is relative, it will be resolved + * relative to the current directory. This will also resolve any ".." that would not work in a + * sandbox profile. Symlinks are resolved so that rules match what the kernel sees. For example, + * /var/folders/../path... becomes /private/var/path... on macOS (/var symlinks to /private/var). + * + * pathStr: the path to create a sandbox profile path from + * executableDirectory: the directory that the executable is in + * + * Returns a normalized, absolute path that can be used in the sandbox profile + */ +std::filesystem::path +createSandboxProfilePath(const std::string& pathStr, + const std::filesystem::path& executableDirectory) +{ + std::filesystem::path path(pathStr); + if (path.is_relative()) { + path = executableDirectory / path; + } + // Resolve any ".." and symlinks that would not work in a sandbox profile + std::error_code ec; // Non-throwing overload to not crash host process + std::filesystem::path canonical = + std::filesystem::weakly_canonical(path.lexically_normal(), ec); + if (ec) { + TF_WARN("(HOST) Failed to canonicalize path \"%s\" for the sandbox profile. " + "Falling back to lexically_normal: %s", + path.string().c_str(), + ec.message().c_str()); + return path.lexically_normal(); + } + return canonical; +} + +/* + * Convert a path into a rule. A rule is: + * (subpath \\\"" + ALLOWED_PATH + "\\\") + * + * path: the path to convert into a rule + * executableDirectory: the directory that the executable is in + * + * Returns a rule that can be added to the sandbox profile + */ +std::string +convertPathToRule(const std::filesystem::path& path, + const std::filesystem::path& executableDirectory) +{ + // Escape the double quotes. Since the command is executed, the string itself must contain + // escape characters. For the string to contain \" it must be written as \\ and \" + // The path is not escaped, because it is in quotes + std::filesystem::path absoluteNormalizedPath = + createSandboxProfilePath(path, executableDirectory); + return " (subpath \\\"" + absoluteNormalizedPath.string() + "\\\")\n"; +} + +/* + * Convert a list of paths into a list of rules. Each rule is: + * (subpath \\\"" + ALLOWED_PATH + "\\\") + * + * paths: the list of paths to convert into rules + * executableDirectory: the directory that the executable is in + * + * Returns a string of rules that can be added to the sandbox profile + */ +std::string +concatPathsIntoRules(const std::vector& paths, + const std::filesystem::path& executableDirectory) +{ + std::string result = ""; + for (const auto& path : paths) { + result += convertPathToRule(path, executableDirectory); + } + return result; +} + +/* + * Replace all instances of a placeholder string in the sandbox profile with a new string. + * + * sandboxProfile: the sandbox profile string to replace the string in. This string may be + * modified in place! + * word: the placeholder string to replace + * replacement: the new string to be added + */ +void +replaceUnresolvedString(std::string& sandboxProfile, + const std::string& word, + const std::string& replacement) +{ + for (size_t strPos = sandboxProfile.find(word); strPos != std::string::npos; + strPos = sandboxProfile.find(word, strPos + replacement.length())) { + sandboxProfile.replace(strPos, word.length(), replacement); + } +} + +// Externally visible utility functions + +// TODO: Consider if sandboxLibraryPath should be a vector of strings instead + +std::string +GenerateSandboxProfile(const std::filesystem::path& resolvedPath, + const std::string& sandboxLibraryPath, + bool isWritingToPath) +{ + std::filesystem::path executableDirectory = getExecutableDirectory(); + + std::string sandboxProfileString = sandboxProfileTemplate; + + std::string assetDirRules = convertPathToRule(resolvedPath.parent_path(), executableDirectory); + std::string tempDirRules = convertPathToRule(hardening::GetTempDirMacOS(), executableDirectory); + if (isWritingToPath) { + // No source directory is needed to write out the asset. Replace the @EXPORT_DIR@ + // placeholder with the export location of the asset + replaceUnresolvedString(sandboxProfileString, "@SOURCE_DIR@", ""); + replaceUnresolvedString(sandboxProfileString, "@EXPORT_DIR@", assetDirRules); + } else { + // Replace the @SOURCE_DIR@ placeholder with the source directory of the asset. No export + // directory is needed to read in the asset + replaceUnresolvedString(sandboxProfileString, "@SOURCE_DIR@", assetDirRules); + replaceUnresolvedString(sandboxProfileString, "@EXPORT_DIR@", ""); + } + + replaceUnresolvedString(sandboxProfileString, "@TEMP_DIR@", tempDirRules); + + // Replace the @LIBRARY_PATHS@ placeholder with the paths to required libraries + // Note that the library path may be a list of paths separated by ':', from the environment. + std::string libraryPathRules = + concatPathsIntoRules(TfStringTokenize(sandboxLibraryPath, ":"), executableDirectory); + if (!libraryPathRules.empty()) { + replaceUnresolvedString(sandboxProfileString, "@LIBRARY_PATHS@", libraryPathRules); + } + + // FOR DEBUGGING: + // When running in projects, it sometimes can be a hassle to access logs. This has been added + // as a temporary debug workaround to view the sandbox profile that has been generated. Set + // the following string to a local filepath where the sandbox profile will be written, and so + // it can be easily viewed. + std::string debugSandboxProfileOutputPath = ""; + if (!debugSandboxProfileOutputPath.empty()) { + std::ofstream sandboxProfileOutput(debugSandboxProfileOutputPath); + if (sandboxProfileOutput.is_open()) { + sandboxProfileOutput << sandboxProfileString; + sandboxProfileOutput.close(); + } else { + TF_WARN("Could not open sandbox profile output file at %s", + debugSandboxProfileOutputPath.c_str()); + } + } + + return sandboxProfileString; +} +} + +#endif // SANDBOX_IS_MACOS diff --git a/sandbox/src/hardening/sandboxProfile.h b/sandbox/src/hardening/sandboxProfile.h new file mode 100644 index 00000000..e2011342 --- /dev/null +++ b/sandbox/src/hardening/sandboxProfile.h @@ -0,0 +1,49 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include + +namespace adobe::usd::sandbox { + +/** + * Generate a sandbox profile for a given source asset. The resulting string can be used for the + * sandbox-exec command on MacOS to allow the sandbox to access a temporary directory, the directory + * of the source asset, and the libraries needed to run the process. + * + * The sandboxLibraryPath variable may be a single path to the directory containing the libraries, + * or a list of paths separated by ':'. If a list of paths is provided, the paths will be added to + * the sandbox profile as separate rules. The latter is needed if not using libraries that are all + * bundled, such as when running the fileformats as a standalone application. In this case, it is + * recommended to use the environment variable LD_LIBRARY_PATH, concatenated with the current + * Python path (separated by ':'). + * + * @param sourceAsset The source asset to generate a sandbox profile for + * @param sandboxLibraryPath The path to the library/libraries to be used by the sandboxed process + * (may be a single path or a list of paths separated by ':') + * @param isWritingToPath Whether the asset is being written to a path. If true, the @EXPORT_DIR@ + * placeholder will be replaced with the source directory of the asset. If + * false, the @SOURCE_DIR@ placeholder will be replaced with the source + * directory of the asset. This should be false for import and true for + * export. + * + * @return A sandbox profile string that can be used to sandbox the process. + */ +std::string +GenerateSandboxProfile(const std::filesystem::path& sourceAsset, + const std::string& sandboxLibraryPath, + bool isWritingToPath); +} diff --git a/sandbox/src/protocol/CMakeLists.txt b/sandbox/src/protocol/CMakeLists.txt new file mode 100644 index 00000000..7852a50d --- /dev/null +++ b/sandbox/src/protocol/CMakeLists.txt @@ -0,0 +1,31 @@ +add_library(sandboxProtocol SHARED + "messages.cpp" + "messageIO.cpp" + "protocolBase.cpp" + "hostProtocol.cpp" + "sandboxProtocol.cpp" + "assetReader.cpp" + "assetWriter.cpp" +) + +usd_plugin_compile_config(sandboxProtocol) +target_compile_definitions(sandboxProtocol PRIVATE USDSANDBOX_EXPORTS) + +target_include_directories(sandboxProtocol +PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/../../include/" +) + +target_link_libraries(sandboxProtocol +PUBLIC + usdIpc + usdSerialization +PRIVATE + usd + ar + tf +) + +set_target_properties(sandboxProtocol PROPERTIES INSTALL_RPATH "${CMAKE_INSTALL_RPATH}") + +install(TARGETS sandboxProtocol) diff --git a/sandbox/src/protocol/assetReader.cpp b/sandbox/src/protocol/assetReader.cpp new file mode 100644 index 00000000..6b6d3935 --- /dev/null +++ b/sandbox/src/protocol/assetReader.cpp @@ -0,0 +1,220 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include +#include + +#include + +#include +#include + +namespace adobe::usd::sandbox { + +using namespace PXR_NS; + +AssetReader::AssetReader(ipc::SharedMemory& shm) + : _shm(shm) +{ + if (!_ReadTableOfContentsIntoCache()) { + TF_WARN("ERROR: Failed to read table of contents from shared memory\n"); + } +} + +std::shared_ptr +AssetReader::ReadAssetFromSharedMemory(const std::string& path) +{ + TF_DEBUG_MSG( + FILE_FORMAT_SANDBOXPROXY, "Reading asset from binary data for path: %s\n", path.c_str()); + + size_t assetSize, assetOffset; + if (!_ReadAssetSizeAndOffset(path, assetSize, assetOffset)) { + TF_WARN("ERROR: Failed to read size and offset from binary data for path: %s", + path.c_str()); + return nullptr; + } + + // Reject sizes that don't fit in shared memory before attempting to allocate the buffer + const size_t shmSize = _shm.GetSize(); + if (assetOffset > shmSize || assetSize > shmSize - assetOffset) { + TF_WARN("ERROR: Asset \"%s\" with size %zu at offset %zu exceeds shared memory size %zu", + path.c_str(), + assetSize, + assetOffset, + shmSize); + return nullptr; + } + + // ArInMemoryAsset::FromBuffer requires a shared_ptr, but the char is a buffer. + // Normally, the shared_ptr would use the char delete, which will cause memory issues if used + // on a char[]. Instead, we explicitly register the char[] delete + std::shared_ptr buffer(new char[assetSize], std::default_delete()); + + // Read the asset data from shared memory into our new buffer + if (!_ReadBuffer(assetSize, buffer.get(), assetOffset)) { + TF_WARN("ERROR: Failed to read asset data for \"%s\" from shared memory", path.c_str()); + return nullptr; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "Successfully read asset from binary data for path: %s\n", + path.c_str()); + + // Create the ArInMemoryAsset from our copied buffer + return std::static_pointer_cast( + ArInMemoryAsset::FromBuffer(std::static_pointer_cast(buffer), assetSize)); +} + +bool +AssetReader::ProcessAssetsFromSharedMemory( + std::function& asset)> + processAssetCallback) +{ + if (_tableOfContents.assetPathsAndInfo.empty()) { + TF_WARN("Warning: No assets found while reading from shared memory\n"); + return true; + } + + for (const auto& [path, _] : _tableOfContents.assetPathsAndInfo) { + std::shared_ptr asset = ReadAssetFromSharedMemory(path); + if (!asset) { + TF_WARN("ERROR: Failed to read asset from binary data for path: %s", path.c_str()); + return false; + } + processAssetCallback(path, asset); + } + return true; +} + +// Private functions + +bool +AssetReader::_ReadTableOfContentsIntoCache() +{ + size_t bytesRead = 0; + + if (!_tableOfContents.assetPathsAndInfo.empty()) { + return true; + } + // Asset's location in the table of contents has not been cached. Reconstruct it + + // Read the number of assets to reconstruct the table of contents + size_t numAssets; + if (!_ReadSizeType(numAssets, bytesRead)) { + TF_WARN("ERROR: Failed to read number of assets from shared memory"); + return false; + } + + for (size_t i = 0; i < numAssets; ++i) { + std::string assetPath; + if (!_ReadAndResizeString(assetPath, bytesRead)) { + TF_WARN("ERROR: Failed to read asset path %zu from shared memory for constructing " + "asset table of contents", + i); + return false; + } + + size_t assetSize; + if (!_ReadSizeType(assetSize, bytesRead)) { + TF_WARN("ERROR: Failed to read asset size for asset \"%s\" from shared memory " + "for constructing asset table of contents", + assetPath.c_str()); + return false; + } + + size_t assetLocation; + if (!_ReadSizeType(assetLocation, bytesRead)) { + TF_WARN("ERROR: Failed to read asset location for asset \"%s\" from shared memory " + "for constructing asset table of contents", + assetPath.c_str()); + return false; + } + + _tableOfContents.assetPathsAndInfo[assetPath] = { assetSize, assetLocation }; + } + return true; +} + +bool +AssetReader::_ReadAssetSizeAndOffset(const std::string& path, + size_t& assetSize, + size_t& assetOffset) +{ + // Read the asset's size and location from the table of contents + const auto it = _tableOfContents.assetPathsAndInfo.find(path); + if (it == _tableOfContents.assetPathsAndInfo.end()) { + TF_WARN("ERROR: Asset \"%s\" not found in shared memory", path.c_str()); + return false; + } + assetSize = it->second.size; + assetOffset = it->second.offset; + + return true; +} + +bool +AssetReader::_ReadRaw(size_t offset, void* buffer, size_t size) +{ + return _shm.Read(offset, buffer, size); +} + +bool +AssetReader::_ReadSizeType(SandboxSizeType& value, size_t& bytesRead) +{ + if (_ReadRaw(bytesRead, &value, sizeof(SandboxSizeType))) { + bytesRead += sizeof(SandboxSizeType); + return true; + } else { + return false; + } +} + +bool +AssetReader::_ReadAndResizeString(std::string& string, size_t& bytesRead) +{ + size_t dataSize; + if (!_ReadSizeType(dataSize, bytesRead)) { + return false; + } + + // Reject lengths that don't fit in shared memory before attempting to allocate the buffer + const size_t shmSize = _shm.GetSize(); + if (bytesRead > shmSize || dataSize > shmSize - bytesRead) { + TF_WARN("ERROR: Declared string size %zu at offset %zu exceeds shared memory size %zu", + dataSize, + bytesRead, + shmSize); + return false; + } + + string.resize(dataSize); + if (_ReadRaw(bytesRead, string.data(), dataSize)) { + bytesRead += dataSize; + return true; + } else { + return false; + } +} + +bool +AssetReader::_ReadBuffer(size_t& size, void* buffer, size_t& bytesRead) +{ + if (_ReadRaw(bytesRead, buffer, size)) { + bytesRead += size; + return true; + } + return false; +} + +} // namespace adobe::usd::sandbox \ No newline at end of file diff --git a/sandbox/src/protocol/assetWriter.cpp b/sandbox/src/protocol/assetWriter.cpp new file mode 100644 index 00000000..0b4bbb9c --- /dev/null +++ b/sandbox/src/protocol/assetWriter.cpp @@ -0,0 +1,219 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include +#include + +#include + +#include +#include + +namespace adobe::usd::sandbox { + +using namespace PXR_NS; + +AssetWriter::AssetWriter(ipc::SharedMemory& shm, const AssetMap& arAssets) + : _shm(shm) + , _assetsToWrite(arAssets) +{ + _SetupTableOfContents(); +} + +size_t +AssetWriter::GetSize() const +{ + // Return the total size needed for storing the table of contents and the assets + return _requiredSize; +} + +bool +AssetWriter::WriteAssetsToSharedMemory() +{ + size_t bytesWritten = 0; + + if (!_WriteTableOfContents(bytesWritten)) { + TF_WARN("ERROR: Failed to write table of contents to shared memory"); + return false; + } + + // Write assets + + for (const auto& [path, asset] : _assetsToWrite) { + + // This check is not necessary for the function to work, but is useful for verifying that + // the assets are at the correct offsets. Otherwise, there will be issues if an asset is + // not where the table of contents says it should be + if (_tableOfContents.assetPathsAndInfo[path].offset != bytesWritten) { + TF_CODING_ERROR( + "Asset \"%s\" is at the wrong offset (expected=%lu, actual=%zu)! The asset is not " + "being written the the location specified in the table of contents!", + path.c_str(), + _tableOfContents.assetPathsAndInfo[path].offset, + bytesWritten); + return false; + } + + // Write the asset data to shared memory. The path and size are not written here because + // they were already written in the table of contents that points here + if (!_WriteBuffer(asset->GetSize(), asset->GetBuffer().get(), bytesWritten)) { + TF_RUNTIME_ERROR("ERROR: Failed to write asset data for \"%s\" to shared memory", + path.c_str()); + // We don't return false so that we continue to write the rest of the assets even if + // one or more textures are missing. If the USDC data is missing, a later step in the + // process will fail. + } + } + return true; +} + +// Private functions + +void +AssetWriter::_SetupTableOfContents() +{ + // First, we calculate the size of the table of contents, so we can properly calculate the + // asset offsets after the table of contents. + size_t tableOfContentsSize = 0; + + // Number of assets to be written at the start of the table of contents + _IncrementBySizeType(tableOfContentsSize); + + for (const auto& [path, asset] : _assetsToWrite) { + // Simulate writing the asset path, size and offset to shared memory, to calculate the size + // of the table of contents + _IncrementBySizeAndString(path, tableOfContentsSize); // Asset path + _IncrementBySizeType(tableOfContentsSize); // Asset size + _IncrementBySizeType(tableOfContentsSize); // Asset offset + } + + // Now that we know how much space the table of contents will take, we can set all the offsets + // properly + _tableOfContents.sizeInBytes = tableOfContentsSize; + + size_t currentOffset = tableOfContentsSize; + for (const auto& [path, asset] : _assetsToWrite) { + + size_t assetSize = asset->GetSize(); + _tableOfContents.assetPathsAndInfo[path] = { assetSize, currentOffset }; + // Increment the offset to where the next asset will be written + currentOffset += assetSize; + } + + // Set the total size needed for storing the table of contents and the assets + _requiredSize = currentOffset; +} + +bool +AssetWriter::_WriteTableOfContents(size_t& bytesWritten) +{ + if (_tableOfContents.sizeInBytes == 0) { + // If initialized, the table of contents will at least store the number of assets. If the + // size is 0, it hasn't been initialized. + TF_CODING_ERROR("ERROR: Table of contents data is not set. It must be constructed before " + "it can be written to shared memory"); + return false; + } + + // Write the number of assets to the shared memory + if (!_WriteSizeType(_tableOfContents.assetPathsAndInfo.size(), bytesWritten)) { + TF_WARN("ERROR: Failed to write number of assets to shared memory"); + return false; + } + + for (const auto& [path, assetInfo] : _tableOfContents.assetPathsAndInfo) { + size_t assetSize = assetInfo.size; + size_t assetOffset = assetInfo.offset; + if (!_WriteSizeAndString(path, bytesWritten)) { + TF_WARN("ERROR: Failed to write asset path \"%s\" to shared memory table of contents", + path.c_str()); + return false; + } + if (!_WriteSizeType(assetSize, bytesWritten)) { + TF_WARN("ERROR: Failed to write asset size (%zu) for asset \"%s\" to shared memory " + "table of contents", + assetSize, + path.c_str()); + return false; + } + if (!_WriteSizeType(assetOffset, bytesWritten)) { + TF_WARN("ERROR: Failed to write asset location (%zu) for asset \"%s\" to shared memory " + "table of contents", + assetOffset, + path.c_str()); + return false; + } + } + + return true; +} + +bool +AssetWriter::_WriteRaw(const void* buffer, size_t size, size_t offset) +{ + return _shm.Write(buffer, size, offset); +} + +bool +AssetWriter::_WriteSizeType(SandboxSizeType value, size_t& bytesWritten) +{ + size_t dataSize = sizeof(SandboxSizeType); + if (!_WriteRaw(&value, dataSize, bytesWritten)) { + return false; + } + bytesWritten += dataSize; + return true; +} + +bool +AssetWriter::_WriteSizeAndString(const std::string& string, size_t& bytesWritten) +{ + size_t dataSize = string.size(); + if (!_WriteSizeType(dataSize, bytesWritten)) { + return false; + } + + if (!_WriteRaw(string.data(), dataSize, bytesWritten)) { + return false; + } + bytesWritten += dataSize; + return true; +} + +bool +AssetWriter::_WriteBuffer(size_t size, const void* buffer, size_t& bytesWritten) +{ + // Warning: this function does not write the size of the buffer! The amount of data written + // must be tracked separately. + if (!_WriteRaw(buffer, size, bytesWritten)) { + return false; + } + + bytesWritten += size; + return true; +} + +void +AssetWriter::_IncrementBySizeType(size_t& bytesWritten) +{ + bytesWritten += sizeof(SandboxSizeType); +} + +void +AssetWriter::_IncrementBySizeAndString(const std::string& string, size_t& bytesWritten) +{ + bytesWritten += sizeof(SandboxSizeType) + string.size(); +} + +} // namespace adobe::usd::sandbox \ No newline at end of file diff --git a/sandbox/src/protocol/hostProtocol.cpp b/sandbox/src/protocol/hostProtocol.cpp new file mode 100644 index 00000000..4d1d2ae6 --- /dev/null +++ b/sandbox/src/protocol/hostProtocol.cpp @@ -0,0 +1,296 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include + +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::sandbox { + +std::atomic HostProtocol::sInstanceCount{ 0 }; + +bool +ValidateReportedAssetSize(size_t assetsSize, bool allowLargeAssets) +{ + if (assetsSize <= kMaxSharedMemorySize) { + return true; + } + if (!allowLargeAssets) { + TF_WARN("(HOST) Sandbox reported asset size %zu exceeding the %zu-byte limit; rejecting. " + "Set the 'sandboxAllowLargeAssets' file format argument to allow larger assets.", + assetsSize, + kMaxSharedMemorySize); + return false; + } + // Over the cap but explicitly allowed. The cap is the host's only defense against a compromised + // sandbox over-reporting its asset size to force a large allocation, and the override disables + // it for this import. + TF_WARN("(HOST) 'sandboxAllowLargeAssets' is set: accepting worker-reported asset size %zu " + "above the %zu-byte cap. The host is not protected against a compromised sandbox " + "over-reporting its asset size to force a large allocation.", + assetsSize, + kMaxSharedMemorySize); + return true; +} + +HostProtocol::HostProtocol() + : ProtocolBase("(HOST)") +{ + ++sInstanceCount; +} + +HostProtocol::~HostProtocol() +{ + _toChildPipe.readEnd.Close(); + _toChildPipe.writeEnd.Close(); + _toParentPipe.readEnd.Close(); + _toParentPipe.writeEnd.Close(); + + // If a worker was launched but never reaped on the normal path (e.g. an early-failure return + // before WaitForCompletion), give it a bounded grace period to exit so its output drains to + // the terminal, then force-kill it so a hung or crashed worker cannot wedge the host. On the + // normal path WaitForCompletion has already reaped it and these calls are no-ops. + if (_process && _state != HostState::Completed) { + int exitCode = -1; + if (!_process->WaitFor(kShutdownTimeoutMs, exitCode)) { + TF_WARN("(HOST) Sandbox worker did not exit within %d ms during shutdown; terminating.", + kShutdownTimeoutMs); + _process->Terminate(); + } + } +} + +bool +HostProtocol::LaunchProcess(const std::string& processPath, + const std::string& resolvedPath, + const std::string& unsafePluginRoot, + bool isExport, + ipc::Process::PosixPreExecHook preExecHook, + const std::string& commandPrefix) +{ + if (_state != HostState::Initialized) { + TF_CODING_ERROR("(HOST) LaunchProcess called in wrong state"); + return false; + } + + if (!ipc::CreatePipePair(_toChildPipe)) { + TF_WARN("(HOST) Failed to create pipe for communicating to child"); + return false; + } + if (!ipc::CreatePipePair(_toParentPipe)) { + TF_WARN("(HOST) Failed to create pipe for communicating from child"); + return false; + } + + // Build command line: [prefix parts...] executable resolvedPath pluginRoot readPipe writePipe + // isExport + std::vector commandAndArgs; + + if (!commandPrefix.empty()) { + // Parse prefix into tokens (for sandbox-exec -p "profile") + std::istringstream prefixStream(commandPrefix); + std::string token; + while (prefixStream >> std::quoted(token)) { + commandAndArgs.push_back(token); + } + } + + commandAndArgs.push_back(processPath); + commandAndArgs.push_back(resolvedPath); + commandAndArgs.push_back(unsafePluginRoot); + commandAndArgs.push_back(_toChildPipe.readEnd.ToString()); + commandAndArgs.push_back(_toParentPipe.writeEnd.ToString()); + commandAndArgs.push_back(isExport ? "true" : "false"); + + _process = ipc::CreateSubprocess(); + if (!_process) { + TF_WARN("(HOST) Failed to create process object"); + return false; + } + if (!_process->Launch(commandAndArgs, preExecHook)) { + TF_WARN("(HOST) Failed to launch sandboxed process"); + return false; + } + + // Close child-side pipe ends in the parent + _toChildPipe.readEnd.Close(); + _toParentPipe.writeEnd.Close(); + + _state = HostState::ProcessLaunched; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) Process launched successfully\n"); + return true; +} + +bool +HostProtocol::SendFileFormatArgs(const std::map& fileFormatArgs) +{ + if (_state != HostState::ProcessLaunched) { + TF_CODING_ERROR("(HOST) SendFileFormatArgs called in wrong state"); + return false; + } + + FileFormatArgsMessage msg; + msg.args = fileFormatArgs; + + serialization::BufferWriter writer; + msg.WriteTo(writer); + + if (!WriteMessage(_toChildPipe.writeEnd, writer.Finish())) { + TF_WARN("(HOST) Failed to send file format arguments"); + return false; + } + + _state = HostState::ArgsSent; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) File format arguments sent\n"); + return true; +} + +bool +HostProtocol::ReceiveAssetSize(size_t& assetsSize, bool allowLargeAssets) +{ + if (_state != HostState::ArgsSent) { + TF_CODING_ERROR("(HOST) ReceiveAssetSize called in wrong state"); + return false; + } + + std::vector data; + if (!ReadMessage(_toParentPipe.readEnd, data)) { + TF_WARN("(HOST) Failed to receive asset size from sandbox"); + return false; + } + + serialization::BufferReader reader(data); + AssetSizeMessage msg = AssetSizeMessage::ReadFrom(reader); + if (reader.HasError()) { + TF_WARN("(HOST) Failed to decode asset size message"); + return false; + } + + if (!ValidateReportedAssetSize(msg.assetsSize, allowLargeAssets)) { + return false; + } + assetsSize = msg.assetsSize; + _state = HostState::SizeReceived; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) Received asset size: %zu\n", assetsSize); + return true; +} + +bool +HostProtocol::CreateSharedMemory(const std::string& shmNamePrefix, size_t dataSize) +{ + if (_state != HostState::SizeReceived && _state != HostState::ArgsSent) { + TF_CODING_ERROR("(HOST) CreateSharedMemory called in wrong state"); + return false; + } + if (dataSize == 0) { + TF_WARN("(HOST) Cannot create shared memory with size 0"); + return false; + } + if (!_sharedMemory) { + TF_WARN("(HOST) Shared memory object not available"); + return false; + } + + _shmName = shmNamePrefix + std::to_string(sInstanceCount.load()); + _shmSize = dataSize; + + if (!_sharedMemory->Create(_shmName, dataSize)) { + TF_WARN("(HOST) Failed to create shared memory '%s'", _shmName.c_str()); + return false; + } + + _state = HostState::ShmCreated; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Shared memory created: name=%s, size=%zu\n", + _shmName.c_str(), + dataSize); + return true; +} + +bool +HostProtocol::AnnounceSharedMemory() +{ + if (_state != HostState::ShmCreated) { + TF_CODING_ERROR("(HOST) AnnounceSharedMemory called in wrong state"); + return false; + } + + SharedMemoryInfoMessage msg; + msg.name = _shmName; + msg.size = _shmSize; + + serialization::BufferWriter writer; + msg.WriteTo(writer); + + if (!WriteMessage(_toChildPipe.writeEnd, writer.Finish())) { + TF_WARN("(HOST) Failed to send shared memory info to sandbox"); + return false; + } + + _state = HostState::ShmReady; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(HOST) Shared memory announced: name=%s, size=%zu\n", + _shmName.c_str(), + _shmSize); + return true; +} + +bool +HostProtocol::InitializeSharedMemory(const std::string& shmNamePrefix, size_t dataSize) +{ + // Import convenience: the worker is the writer and the host blocks on ReceiveAssetSize before + // calling this, so creating and announcing together is race-free here. Export must instead use + // CreateSharedMemory -> write payload -> AnnounceSharedMemory. + if (_state != HostState::SizeReceived) { + TF_CODING_ERROR("(HOST) InitializeSharedMemory called in wrong state"); + return false; + } + return CreateSharedMemory(shmNamePrefix, dataSize) && AnnounceSharedMemory(); +} + +bool +HostProtocol::WaitForCompletion() +{ + if (_state != HostState::ShmReady) { + TF_CODING_ERROR("(HOST) WaitForCompletion called in wrong state"); + return false; + } + if (!_process) { + TF_WARN("(HOST) No process to wait for"); + return false; + } + + int exitCode = -1; + if (!_process->Wait(exitCode)) { + TF_WARN("(HOST) Failed to wait for sandboxed process"); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(HOST) Sandboxed process exit code: %d\n", exitCode); + + if (exitCode != 0) { + TF_WARN("(HOST) Sandboxed process exited with code: %d", exitCode); + return false; + } + + _state = HostState::Completed; + return true; +} + +} // namespace adobe::usd::sandbox diff --git a/sandbox/src/protocol/messageIO.cpp b/sandbox/src/protocol/messageIO.cpp new file mode 100644 index 00000000..e99e80c6 --- /dev/null +++ b/sandbox/src/protocol/messageIO.cpp @@ -0,0 +1,70 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::sandbox { + +bool +WriteMessageToPipe(const ipc::PipeHandle& pipe, const std::vector& data) +{ + if (data.size() > kMaxMessageSize) { + TF_WARN("Message size %zu exceeds maximum %u; refusing to send", + data.size(), + static_cast(kMaxMessageSize)); + return false; + } + uint32_t size = static_cast(data.size()); + if (!pipe.Write(&size, sizeof(size))) { + TF_WARN("Failed to write message size to pipe"); + return false; + } + if (!pipe.Write(data.data(), size)) { + TF_WARN("Failed to write message data to pipe"); + return false; + } + return true; +} + +bool +ReadMessageFromPipe(const ipc::PipeHandle& pipe, std::vector& out) +{ + out.clear(); + + uint32_t size = 0; + if (!pipe.Read(&size, sizeof(size))) { + TF_WARN("Failed to read message size from pipe. Peer may have crashed."); + return false; + } + if (size == 0) { + TF_WARN("Received zero-length message from pipe"); + return false; + } + if (size > kMaxMessageSize) { + TF_WARN("Declared message size %u exceeds maximum %u; rejecting", size, kMaxMessageSize); + return false; + } + + out.resize(size); + if (!pipe.Read(out.data(), size)) { + TF_WARN("Failed to read message data from pipe. Peer may have crashed."); + out.clear(); + return false; + } + return true; +} + +} // namespace adobe::usd::sandbox diff --git a/sandbox/src/protocol/messages.cpp b/sandbox/src/protocol/messages.cpp new file mode 100644 index 00000000..51ae155b --- /dev/null +++ b/sandbox/src/protocol/messages.cpp @@ -0,0 +1,61 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +namespace adobe::usd::sandbox { + +void +FileFormatArgsMessage::WriteTo(serialization::BufferWriter& writer) const +{ + writer.WriteMap(args); +} + +FileFormatArgsMessage +FileFormatArgsMessage::ReadFrom(serialization::BufferReader& reader) +{ + FileFormatArgsMessage msg; + msg.args = reader.ReadMap(); + return msg; +} + +void +AssetSizeMessage::WriteTo(serialization::BufferWriter& writer) const +{ + writer.WriteSizeT(assetsSize); +} + +AssetSizeMessage +AssetSizeMessage::ReadFrom(serialization::BufferReader& reader) +{ + AssetSizeMessage msg; + msg.assetsSize = reader.ReadSizeT(); + return msg; +} + +void +SharedMemoryInfoMessage::WriteTo(serialization::BufferWriter& writer) const +{ + writer.WriteString(name); + writer.WriteSizeT(size); +} + +SharedMemoryInfoMessage +SharedMemoryInfoMessage::ReadFrom(serialization::BufferReader& reader) +{ + SharedMemoryInfoMessage msg; + msg.name = reader.ReadString(); + msg.size = reader.ReadSizeT(); + return msg; +} + +} // namespace adobe::usd::sandbox diff --git a/sandbox/src/protocol/protocolBase.cpp b/sandbox/src/protocol/protocolBase.cpp new file mode 100644 index 00000000..dcdb89a7 --- /dev/null +++ b/sandbox/src/protocol/protocolBase.cpp @@ -0,0 +1,76 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::sandbox { + +ProtocolBase::ProtocolBase(const char* sideTag) + : _sideTag(sideTag) +{ + _sharedMemory = ipc::CreateSharedMemory(); + if (!_sharedMemory) { + TF_WARN("%s Shared memory object not available", _sideTag); + } +} + +ProtocolBase::~ProtocolBase() = default; + +ipc::SharedMemory& +ProtocolBase::GetSharedMemory() +{ + // The shared-memory object is created in the constructor and is required for every asset + // transfer. A null here means construction failed (see the constructor's warning) and there + // is no recovery path. + if (!_sharedMemory) { + TF_FATAL_ERROR("%s Shared memory object is null; exiting", _sideTag); + } + return *_sharedMemory; +} + +void +ProtocolBase::CleanSharedMemory() +{ + if (!_sharedMemory) { + TF_WARN("%s Shared memory object not available", _sideTag); + return; + } + _sharedMemory->Clean(); +} + +bool +ProtocolBase::WriteMessage(const ipc::PipeHandle& pipe, const std::vector& data) +{ + if (!WriteMessageToPipe(pipe, data)) { + TF_WARN("%s Failed to send message", _sideTag); + return false; + } + return true; +} + +bool +ProtocolBase::ReadMessage(const ipc::PipeHandle& pipe, std::vector& data) +{ + if (!ReadMessageFromPipe(pipe, data)) { + TF_WARN("%s Failed to receive message", _sideTag); + return false; + } + return true; +} + +} // namespace adobe::usd::sandbox diff --git a/sandbox/src/protocol/sandboxProtocol.cpp b/sandbox/src/protocol/sandboxProtocol.cpp new file mode 100644 index 00000000..dd155a61 --- /dev/null +++ b/sandbox/src/protocol/sandboxProtocol.cpp @@ -0,0 +1,126 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace adobe::usd::sandbox { + +SandboxProtocol::SandboxProtocol(const std::string& readPipeStr, const std::string& writePipeStr) + : ProtocolBase("(SANDBOX)") + , _readPipe(ipc::PipeHandle::FromString(readPipeStr)) + , _writePipe(ipc::PipeHandle::FromString(writePipeStr)) +{} + +SandboxProtocol::~SandboxProtocol() +{ + _readPipe.Close(); + _writePipe.Close(); +} + +bool +SandboxProtocol::ReceiveFileFormatArgs(std::map& fileFormatArgs) +{ + if (_state != SandboxState::Initialized) { + TF_CODING_ERROR("(SANDBOX) ReceiveFileFormatArgs called in wrong state"); + return false; + } + + std::vector data; + if (!ReadMessage(_readPipe, data)) { + TF_WARN("(SANDBOX) Failed to receive file format arguments from host"); + return false; + } + + serialization::BufferReader reader(data); + FileFormatArgsMessage msg = FileFormatArgsMessage::ReadFrom(reader); + if (reader.HasError()) { + TF_WARN("(SANDBOX) Failed to decode file format arguments message"); + return false; + } + + fileFormatArgs = msg.args; + _state = SandboxState::ArgsReceived; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Received file format arguments\n"); + return true; +} + +bool +SandboxProtocol::SendAssetSize(size_t assetsSize) +{ + if (_state != SandboxState::ArgsReceived) { + TF_CODING_ERROR("(SANDBOX) SendAssetSize called in wrong state"); + return false; + } + + AssetSizeMessage msg; + msg.assetsSize = assetsSize; + + serialization::BufferWriter writer; + msg.WriteTo(writer); + + if (!WriteMessage(_writePipe, writer.Finish())) { + TF_WARN("(SANDBOX) Failed to send asset size to host"); + return false; + } + + _state = SandboxState::SizeSent; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Sent asset size: %zu\n", assetsSize); + return true; +} + +bool +SandboxProtocol::ReceiveAndConnectSharedMemory() +{ + if (_state != SandboxState::SizeSent && _state != SandboxState::ArgsReceived) { + TF_CODING_ERROR("(SANDBOX) ReceiveAndConnectSharedMemory called in wrong state"); + return false; + } + if (!_sharedMemory) { + TF_WARN("(SANDBOX) Shared memory object not available"); + return false; + } + + std::vector data; + if (!ReadMessage(_readPipe, data)) { + TF_WARN("(SANDBOX) Failed to receive shared memory info from host"); + return false; + } + + serialization::BufferReader reader(data); + SharedMemoryInfoMessage msg = SharedMemoryInfoMessage::ReadFrom(reader); + if (reader.HasError()) { + TF_WARN("(SANDBOX) Failed to decode shared memory info message"); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(SANDBOX) Connecting to shared memory: name=%s, size=%zu\n", + msg.name.c_str(), + msg.size); + + if (!_sharedMemory->Connect(msg.name, msg.size)) { + TF_WARN("(SANDBOX) Failed to connect to shared memory '%s'", msg.name.c_str()); + return false; + } + + _state = SandboxState::ShmConnected; + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Connected to shared memory\n"); + return true; +} + +} // namespace adobe::usd::sandbox diff --git a/sandbox/src/resolver/CMakeLists.txt b/sandbox/src/resolver/CMakeLists.txt new file mode 100644 index 00000000..505185b4 --- /dev/null +++ b/sandbox/src/resolver/CMakeLists.txt @@ -0,0 +1,68 @@ +add_library(usdInMemResolver SHARED) +target_compile_definitions(usdInMemResolver PRIVATE USDINMEMRESOLVER_EXPORTS) +usd_plugin_compile_config(usdInMemResolver) + +target_sources(usdInMemResolver +PRIVATE + "inMemoryResolver.cpp" + "inMemoryWritableAsset.cpp" + "badAssetResolver.cpp" +) + +target_include_directories(usdInMemResolver +PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../../include" +) + +target_link_libraries(usdInMemResolver +PRIVATE + usd + fileformatUtils + sandboxUtils +) + +if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") + set(PLUG_INFO_SANDBOX_LIBRARY_PATH "@PLUG_INFO_SANDBOX_LIBRARY_PATH@") + +else() + + set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}usdInMemResolver${CMAKE_SHARED_LIBRARY_SUFFIX}") + if (APPLE) + get_filename_component(Python_BASE_DIR ${Python3_RUNTIME_LIBRARY_DIRS} DIRECTORY) + if (NOT DEFINED PLUG_INFO_SANDBOX_LIBRARY_PATH) + set(PLUG_INFO_SANDBOX_LIBRARY_PATH "$ENV{LD_LIBRARY_PATH}:${Python_BASE_DIR}/Python") + endif() + else() + set(PLUG_INFO_SANDBOX_LIBRARY_PATH "NOT USED ON THIS OS") + endif() +endif() + +configure_file(plugInfo.json.in plugInfo.json) +set_target_properties(usdInMemResolver PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) + +set_target_properties(usdInMemResolver PROPERTIES RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") + +message(STATUS "CMAKE_INSTALL_RPATH: ${CMAKE_INSTALL_RPATH}") +set_target_properties(usdInMemResolver PROPERTIES INSTALL_RPATH "${CMAKE_INSTALL_RPATH}") + +# Installation of plugin files mimics the file structure that USD has for plugins, +# so it is easy to deploy it in a pre-existing USD build, if one chooses to do so. + +# Allow an option for deferring the path replacement to install time + +if(USD_SANDBOXPROXY_ENABLE_INSTALL) + # Because the in-memory resolver is used by both sandboxed and non-sandboxed plugins, we need to + # install it to both plugin_proxy (a separate location from the normal plugins) and + # plugin_sandboxed + set(DESTINATIONS "plugin_proxy/usd" "plugin_sandboxed/usd") + + foreach(DEST ${DESTINATIONS}) + install( + TARGETS usdInMemResolver + RUNTIME DESTINATION ${DEST} COMPONENT Runtime + LIBRARY DESTINATION ${DEST} COMPONENT Runtime + RESOURCE DESTINATION ${DEST}/usdInMemResolver/resources COMPONENT Runtime + ) + endforeach() +endif() diff --git a/sandbox/src/resolver/badAssetResolver.cpp b/sandbox/src/resolver/badAssetResolver.cpp new file mode 100644 index 00000000..280f7596 --- /dev/null +++ b/sandbox/src/resolver/badAssetResolver.cpp @@ -0,0 +1,86 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include +#include + +using namespace PXR_NS; + +namespace adobe::usd::sandbox { + +AR_DEFINE_RESOLVER(adobe::usd::sandbox::BadAssetResolver, ArResolver); + +namespace { + +bool +hasBadAssetScheme(const std::string& path) +{ + return TfStringStartsWith(path, std::string(kBadAssetScheme)); +} + +} + +BadAssetResolver::BadAssetResolver() = default; + +BadAssetResolver::~BadAssetResolver() = default; + +ArResolvedPath +BadAssetResolver::_Resolve(const std::string& path) const +{ + // Echo a well-formed quarantine URI back unchanged so it has a stable, opaque identity. Never + // touch the filesystem or any other resolver. + if (hasBadAssetScheme(path)) { + return ArResolvedPath(path); + } + return ArResolvedPath(); +} + +ArResolvedPath +BadAssetResolver::_ResolveForNewAsset(const std::string& assetPath) const +{ + if (hasBadAssetScheme(assetPath)) { + return ArResolvedPath(assetPath); + } + return ArResolvedPath(); +} + +std::shared_ptr +BadAssetResolver::_OpenAsset(const ArResolvedPath& /* resolvedPath */) const +{ + // Inert by design: a quarantined reference never yields any bytes. + return nullptr; +} + +std::shared_ptr +BadAssetResolver::_OpenAssetForWrite(const ArResolvedPath& /* resolvedPath */, + WriteMode /* writeMode */) const +{ + return nullptr; +} + +std::string +BadAssetResolver::_CreateIdentifier(const std::string& assetPath, + const ArResolvedPath& /* anchorAssetPath */) const +{ + return assetPath; +} + +std::string +BadAssetResolver::_CreateIdentifierForNewAsset(const std::string& assetPath, + const ArResolvedPath& /* anchorAssetPath */) const +{ + return assetPath; +} + +} diff --git a/sandbox/src/resolver/inMemoryResolver.cpp b/sandbox/src/resolver/inMemoryResolver.cpp new file mode 100644 index 00000000..84526aa0 --- /dev/null +++ b/sandbox/src/resolver/inMemoryResolver.cpp @@ -0,0 +1,158 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include +#include +#include +#include +#include + +using namespace PXR_NS; + +namespace adobe::usd::sandbox { + +AR_DEFINE_RESOLVER(adobe::usd::sandbox::InMemoryResolver, ArResolver); + +InMemoryResolver::InMemoryResolver() {} + +InMemoryResolver::~InMemoryResolver() {} + +bool +InMemoryResolver::SetData(const std::string& uri, const std::vector& data) +{ + if (!TfStringStartsWith(uri, "InMemory://")) { + TF_WARN("InMemoryResolver: URI '%s' does not start with 'InMemory://'", uri.c_str()); + return false; + } + _storage[uri] = data; + return true; +} + +bool +InMemoryResolver::GetData(const std::string& uri, std::vector& outData) const +{ + auto it = _storage.find(uri); + if (it != _storage.end()) { + outData = it->second; + return true; + } + return false; +} + +void +InMemoryResolver::_BeginCacheScope(VtValue* cacheScopeData) +{} +void +InMemoryResolver::_EndCacheScope(VtValue* cacheScopeData) +{} + +ArResolvedPath +InMemoryResolver::_Resolve(const std::string& path) const +{ + if (TfStringStartsWith(path, "InMemory://")) { + return ArResolvedPath(path); + } + return ArResolvedPath(); +} + +ArResolvedPath +InMemoryResolver::_ResolveForNewAsset(const std::string& assetPath) const +{ + if (TfStringStartsWith(assetPath, "InMemory://")) { + return ArResolvedPath(assetPath); + } + return ArResolvedPath(); +} + +std::shared_ptr +InMemoryResolver::_OpenAsset(const PXR_NS::ArResolvedPath& resolvedPath) const +{ + const std::string& uri = resolvedPath.GetPathString(); + + if (!TfStringStartsWith(uri, "InMemory://")) { + TF_WARN("Invalid uri: not handled by this resolver"); + return nullptr; + } + + auto it = _storage.find(uri); + if (it == _storage.end()) { + TF_WARN("InMemoryResolver: Asset not found: %s", uri.c_str()); + return nullptr; + } + + // TODO: Verify that this is correct or replace it when refactoring how this class stores data + return ArInMemoryAsset::FromBuffer( + std::shared_ptr(it->second.data(), [](const char*) {}), it->second.size()); +} + +std::shared_ptr +InMemoryResolver::_OpenAssetForWrite(const ArResolvedPath& resolvedPath, WriteMode writeMode) const +{ + const std::string& uri = resolvedPath.GetPathString(); + + if (!TfStringStartsWith(uri, "InMemory://")) { + TF_WARN("Invalid uri: not handled by this resolver"); + return nullptr; + } + + auto it = _storage.find(uri); + if (it == _storage.end()) { + if (writeMode == WriteMode::Replace || writeMode == WriteMode::Update) { + // Create a new buffer for writing + _storage[uri] = std::vector(); + it = _storage.find(uri); + } else { + TF_WARN("InMemoryResolver: Unsupported write mode for URI '%s'", uri.c_str()); + return nullptr; + } + } else { + if (writeMode == WriteMode::Replace) { + // Clear existing data + it->second.clear(); + } + } + return std::make_shared(it->second); +} + +std::string +InMemoryResolver::_CreateIdentifier(const std::string& assetPath, + const ArResolvedPath& anchorAssetPath) const +{ + return assetPath; +} + +std::string +InMemoryResolver::_CreateIdentifierForNewAsset(const std::string& assetPath, + const ArResolvedPath& anchorAssetPath) const +{ + return assetPath; +} + +bool +InMemoryResolver::_CanWriteAssetToPath(const ArResolvedPath& resolvedPath, + std::string* whyNot) const +{ + const std::string& uri = resolvedPath.GetPathString(); + if (!TfStringStartsWith(uri, "InMemory://")) { + if (whyNot) { + *whyNot = "URI scheme not supported by InMemoryResolver."; + } + return false; + } + return true; +} + +} \ No newline at end of file diff --git a/sandbox/src/resolver/inMemoryWritableAsset.cpp b/sandbox/src/resolver/inMemoryWritableAsset.cpp new file mode 100644 index 00000000..67ec00e4 --- /dev/null +++ b/sandbox/src/resolver/inMemoryWritableAsset.cpp @@ -0,0 +1,56 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include + +using namespace PXR_NS; + +namespace adobe::usd::sandbox { + +InMemoryWritableAsset::InMemoryWritableAsset(std::vector& data) + : _buffer(data) +{} + +InMemoryWritableAsset::~InMemoryWritableAsset() {} + +bool +InMemoryWritableAsset::Close() +{ + return false; + // TODO: investigate if changing this to true removes the layer->export failure. It may have + // caused other issues. Do this alongside checking that layer->export returns true. + // return true; +} + +size_t +InMemoryWritableAsset::Write(const void* buffer, size_t count, size_t offset) +{ + // We don't know the size of the data beforehand, so as we write, we may need to resize. We + // won't always need to, because chunks may come out of order, so we don't want to downsize + // the buffer and lose chunks later on that have already been written. + if (offset + count > _buffer.size()) { + _buffer.resize(offset + count); + } + std::memcpy(_buffer.data() + offset, buffer, count); + return count; +} + +const std::vector& +InMemoryWritableAsset::GetBuffer() const +{ + return _buffer; +} +} diff --git a/sandbox/src/resolver/plugInfo.json.in b/sandbox/src/resolver/plugInfo.json.in new file mode 100644 index 00000000..3e9b5f07 --- /dev/null +++ b/sandbox/src/resolver/plugInfo.json.in @@ -0,0 +1,23 @@ +{ + "Plugins": [ + { + "Info": { + "Types": { + "adobe::usd::sandbox::InMemoryResolver": { + "bases": ["ArResolver"], + "uriSchemes": ["InMemory"] + }, + "adobe::usd::sandbox::BadAssetResolver": { + "bases": ["ArResolver"], + "uriSchemes": ["BadAsset"] + } + } + }, + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", + "Name": "usdInMemResolver_plugin", + "ResourcePath": "resources", + "Root": "..", + "Type": "library" + } + ] +} \ No newline at end of file diff --git a/sandbox/src/resolver/plugInfo.root.json b/sandbox/src/resolver/plugInfo.root.json new file mode 100644 index 00000000..2e20f3d6 --- /dev/null +++ b/sandbox/src/resolver/plugInfo.root.json @@ -0,0 +1,3 @@ +{ + "Includes": [ "*/resources/" ] +} diff --git a/sandbox/src/restricted/CMakeLists.txt b/sandbox/src/restricted/CMakeLists.txt new file mode 100644 index 00000000..bfb041e8 --- /dev/null +++ b/sandbox/src/restricted/CMakeLists.txt @@ -0,0 +1,99 @@ +# Define a new executable target for SandboxedProcess.exe to convert files safely +add_executable(SandboxedProcess + "sandboxedProcess.cpp" +) + +# Include directories +target_include_directories(SandboxedProcess + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../../include" +) + +if(WIN32) + # Prevent windows.h includes from bringing in conflicts with C++ STL + target_compile_definitions(SandboxedProcess PRIVATE NOMINMAX) +endif() + +# Binary-hardening flags for SandboxedProcess. This target does not route +# through usd_plugin_compile_config (it is an executable, not a plugin +# shared library), so flags are applied directly here. +if(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") + target_compile_options(SandboxedProcess PRIVATE + /GS # stack buffer security checks (assert default-on, prevent regression) + ) + target_link_options(SandboxedProcess PRIVATE + /DYNAMICBASE # enable ASLR + /HIGHENTROPYVA # use full 64-bit address space for ASLR entropy + ) +else() + # Stack-canary protection and FORTIFY for GCC and Clang (Linux + macOS). + target_compile_options(SandboxedProcess PRIVATE + -fstack-protector-strong + # _FORTIFY_SOURCE=2 requires -O1 or higher; warn-free only under optimisation. + $<$:-D_FORTIFY_SOURCE=2> + ) + if(UNIX AND NOT APPLE) + # Full RELRO: lock the GOT read-only after startup. + # -pie/-fPIE is handled by POSITION_INDEPENDENT_CODE below. + target_link_options(SandboxedProcess PRIVATE + -Wl,-z,relro + -Wl,-z,now + ) + endif() +endif() + +target_link_libraries(SandboxedProcess +PRIVATE + tf + sdf + usd + arch + fileformatUtils + sandboxUtils + sandboxProtocol + sandboxHardening +) + +# SandboxedProcess must own the libpython link on macOS. +# This standalone executable links the USD core libraries (tf, sdf, usd, ...). +# When USD is built with PXR_PY_UNDEFINED_DYNAMIC_LOOKUP=ON (the macOS default), +# those dylibs leave Python symbols (e.g. _Py_NoneStruct) unresolved for +# flat-namespace dynamic lookup, expecting the host process to have already +# loaded libpython. Unlike the main application, this process does not embed the +# Python interpreter, so nothing loads libpython and dyld aborts at launch with +# "symbol not found in flat namespace '__Py_NoneStruct'". Linking Python::Python +# forces dyld to load the interpreter before any USD dylib touches the Python C +# API. This mirrors the pxr_cpp_bin() change in USD's cmake/macros/Public.cmake +# that lets pxr tools such as usdcat run as standalone executables. +if(APPLE) + find_package(Python REQUIRED COMPONENTS Development) + if(TARGET Python::Python) + target_link_libraries(SandboxedProcess PRIVATE Python::Python) + endif() +endif() + +# Assert PIE (Position Independent Executable) on Linux and macOS. +# Linux: required for correctness -- patchelf >= 0.17.2 corrupts non-PIE ELF executables +# in a way that newer kernels (>= 4.18) reject at load time with SIGSEGV. +# macOS: executables are PIE by default, but we assert it explicitly so the property +# cannot be silently lost by a toolchain or CMake version change. +# CheckPIESupported wires up both the -fPIE compile flag (via POSITION_INDEPENDENT_CODE) +# and the -pie linker flag. +if(UNIX) + include(CheckPIESupported) + check_pie_supported() + set_target_properties(SandboxedProcess PROPERTIES POSITION_INDEPENDENT_CODE ON) +endif() + +set_target_properties(SandboxedProcess PROPERTIES INSTALL_RPATH "${CMAKE_INSTALL_RPATH}") + +# set(DEST plugin/usd) +set(DEST plugin_sandboxed/usd) + +if(USD_SANDBOXPROXY_ENABLE_INSTALL) + install( + TARGETS SandboxedProcess + RUNTIME DESTINATION ${DEST} COMPONENT Runtime + LIBRARY DESTINATION ${DEST} COMPONENT Runtime + ) +endif() diff --git a/sandbox/src/restricted/sandboxedProcess.cpp b/sandbox/src/restricted/sandboxedProcess.cpp new file mode 100644 index 00000000..9fc18b72 --- /dev/null +++ b/sandbox/src/restricted/sandboxedProcess.cpp @@ -0,0 +1,283 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include +#include +#include +#include +#include +#include +// Defines inMemoryURI +#include + +#include +#include +#include +#include +#include +#include +#include +// to remove +#include +#include +#include + +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +using namespace adobe::usd::sandbox; + +#define INVALID_RETURN_CODE -1 + +/* + * Release shared memory and flush standard output streams before the sandboxed process exits. + * + * protocol: the active sandbox protocol whose shared memory segment should be cleaned up. + */ +void +CleanUpSharedResources(adobe::usd::sandbox::SandboxProtocol& protocol) +{ + protocol.CleanSharedMemory(); + fflush(stdout); + fflush(stderr); +} + +/* + * Run the import side of the sandbox protocol: open the asset at resolvedPath, serialize it + * as in-memory USDC together with all referenced textures, and write the result into shared + * memory for the host process to consume. + * + * resolvedPath: absolute path to the source asset to import. + * protocol: the active sandbox protocol used for IPC and shared memory. + * + * Returns true on success; false on any failure. + */ +bool +SandboxedImport(const std::string& resolvedPath, SandboxProtocol& protocol) +{ + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Reading fileformat arguments\n"); + std::map fileFormatArgs; + if (!protocol.ReceiveFileFormatArgs(fileFormatArgs)) { + TF_WARN("(SANDBOX) Failed to read pipe arguments from host.\n"); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Opening layer\n"); + SdfLayerRefPtr layer = SdfLayer::FindOrOpen(resolvedPath, fileFormatArgs); + if (!layer) { + TF_WARN("(SANDBOX) Failed to open document: %s\n", resolvedPath.c_str()); + return false; + } + + if (ArGetResolver().OpenAssetForWrite(ArResolvedPath(inMemoryURI), + ArResolver::WriteMode::Update) == nullptr) { + TF_WARN("(SANDBOX) Failed to open USD writable asset for writing: %s\n", + inMemoryURI.c_str()); + return false; + } + + // TODO: Ensure this returns true. It currently doesn't because InMemoryWritableAsset::Close() + // always returns false. Changing that currently would cause USD to crash because of how + // crate files assume memory maps can be used even when there is no actual file. Before the + // return value of this function can be used, USD must be patched + layer->Export(inMemoryURI); + + // TODO: While we can't check that Export(...) succeeded, as a temporary workaround we can try + // to reload the file to confirm that it was created properly. This check should be removed + // when we can trust the return value of layer->Export() (requires USD fix). + SdfLayerRefPtr inMemLayer = SdfLayer::FindOrOpen(inMemoryURI); + if (!inMemLayer) { + TF_RUNTIME_ERROR("(SANDBOX) Failed to write USD layer as USDC data for in memory asset " + "\"%s\". The layer may be malformed.\n", + inMemoryURI.c_str()); + return false; + } + + std::unordered_map assets = FindAssetPaths(layer); + assets.insert({ inMemoryURI, inMemoryURI }); + + AssetMap arAssets = GetArAssets(assets); + AssetWriter assetWriter(protocol.GetSharedMemory(), arAssets); + size_t assetsSize = assetWriter.GetSize(); + if (assetsSize == 0) { + TF_WARN("(SANDBOX) Failed to set assets to write and get size in bytes\n"); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Sending assets size\n"); + if (!protocol.SendAssetSize(assetsSize)) { + TF_WARN("(SANDBOX) Failed to send assets size\n"); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Connecting to shared memory\n"); + if (!protocol.ReceiveAndConnectSharedMemory()) { + TF_WARN("(SANDBOX) Failed to connect to shared memory\n"); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(SANDBOX) Found %zu assets in asset layer %s, writing to shared memory\n", + assets.size(), + layer->GetIdentifier().c_str()); + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Writing assets to shared memory\n"); + if (!assetWriter.WriteAssetsToSharedMemory()) { + TF_WARN("(SANDBOX) Failed to write assets for layer: %s\n", resolvedPath.c_str()); + return false; + } + return true; +} + +/* + * Run the export side of the sandbox protocol: read pre-loaded assets from shared memory, + * register them with the in-memory resolver, and export the USD layer to resolvedPath. + * + * resolvedPath: absolute path where the exported asset should be written. + * protocol: the active sandbox protocol used for IPC and shared memory. + * + * Returns true on success; false on any failure. + */ +bool +SandboxedExport(const std::string& resolvedPath, SandboxProtocol& protocol) +{ + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Reading fileformat arguments\n"); + std::map fileFormatArgs; + if (!protocol.ReceiveFileFormatArgs(fileFormatArgs)) { + TF_WARN("(SANDBOX) Failed to read pipe arguments from host.\n"); + return false; + } + + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Connecting to shared memory\n"); + if (!protocol.ReceiveAndConnectSharedMemory()) { + TF_WARN("(SANDBOX) Failed to connect to shared memory\n"); + return false; + } + + AssetReader assetReader(protocol.GetSharedMemory()); + assetReader.ProcessAssetsFromSharedMemory( + [](const std::string& path, const std::shared_ptr& asset) { + auto writableAsset = + ArGetResolver().OpenAssetForWrite(ArResolvedPath(path), ArResolver::WriteMode::Replace); + if (writableAsset) { + std::vector buffer(asset->GetSize()); + asset->Read(buffer.data(), buffer.size(), 0); + writableAsset->Write(buffer.data(), buffer.size(), 0); + writableAsset->Close(); + } else { + TF_WARN("(SANDBOX) Failed to open asset for writing: %s\n", path.c_str()); + } + }); + + SdfLayerRefPtr layer = SdfLayer::FindOrOpen(inMemoryURI); + if (!layer) { + TF_WARN("(SANDBOX) Failed to open document: %s\n", inMemoryURI.c_str()); + return false; + } + + // Propagate a failed export to the host via a non-zero exit (return false). + if (!layer->Export(resolvedPath, std::string(), fileFormatArgs)) { + TF_WARN("(SANDBOX) Failed to export asset to %s in the sandboxed process\n", + resolvedPath.c_str()); + return false; + } + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Exported layer: %s\n", resolvedPath.c_str()); + return true; +} + +// Entry point of the sandboxed process +int +main(int argc, char* argv[]) +{ + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(SANDBOX) Using %s as plugin path for sandbox server\n", + ArchGetEnv("PXR_PLUGINPATH_NAME").c_str()); + if (argc != 6) { + TF_WARN("(SANDBOX) Incorrect number of arguments.\n"); + TF_WARN("Usage: SandboxedProcess.exe " + " \n"); + return INVALID_RETURN_CODE; + } + + std::string resolvedPath = argv[1]; + std::string unsafePluginRoot = argv[2]; + + std::string readPipeToSandbox = argv[3]; + std::string writePipeFromSandbox = argv[4]; + SandboxProtocol protocol(readPipeToSandbox, writePipeFromSandbox); + + std::string isExportStr = argv[5]; + std::transform(isExportStr.begin(), isExportStr.end(), isExportStr.begin(), ::tolower); + bool isExport; + if (isExportStr == "true") { + isExport = true; + } else if (isExportStr == "false") { + isExport = false; + } else { + TF_WARN("(SANDBOX) Invalid 5th argument: %s. Export flag must be true or false\n", + isExportStr.c_str()); + return INVALID_RETURN_CODE; + } + + PlugRegistry& registry = PlugRegistry::GetInstance(); + std::filesystem::path pluginRoot(unsafePluginRoot); + if (!std::filesystem::is_directory(unsafePluginRoot)) { + TF_WARN("(SANDBOX) Plugin path doesn't exist: %s\n", unsafePluginRoot.c_str()); + return INVALID_RETURN_CODE; + } + + registry.RegisterPlugins(unsafePluginRoot); + auto allPlugins = registry.GetAllPlugins(); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "(SANDBOX) Registered plugins (from %s):\n", + unsafePluginRoot.c_str()); + for (const auto& plugin : allPlugins) { + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + " - %s at %s\n", + plugin->GetName().c_str(), + plugin->GetPath().c_str()); + } + + // Apply process restrictions AFTER plugin registration (so registration's syscalls are not + // blocked) but BEFORE any untrusted document is opened or parsed. + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Applying process restrictions\n"); + if (!adobe::usd::sandbox::hardening::ApplyProcessRestrictions(isExport)) { + TF_WARN("(SANDBOX): Failed to apply process restrictions.\n"); + CleanUpSharedResources(protocol); + return INVALID_RETURN_CODE; + } + + if (isExport) { + TF_DEBUG_MSG( + FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Exporting layer: %s\n", resolvedPath.c_str()); + if (!SandboxedExport(resolvedPath, protocol)) { + CleanUpSharedResources(protocol); + return INVALID_RETURN_CODE; + } + } else { + TF_DEBUG_MSG( + FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Importing layer: %s\n", resolvedPath.c_str()); + if (!SandboxedImport(resolvedPath, protocol)) { + CleanUpSharedResources(protocol); + return INVALID_RETURN_CODE; + } + } + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Conversion complete, cleaning up\n"); + CleanUpSharedResources(protocol); + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "(SANDBOX) Sandboxed process complete\n"); + return 0; +} \ No newline at end of file diff --git a/sandbox/src/utilities/CMakeLists.txt b/sandbox/src/utilities/CMakeLists.txt new file mode 100644 index 00000000..0f90247f --- /dev/null +++ b/sandbox/src/utilities/CMakeLists.txt @@ -0,0 +1,34 @@ +add_library(sandboxUtils SHARED) + +usd_plugin_compile_config(sandboxUtils) +target_compile_definitions(sandboxUtils PRIVATE USDSANDBOX_EXPORTS) + +target_sources(sandboxUtils +PRIVATE + "utilities.cpp" + "base64url.cpp" + "quarantine.cpp" + "sandboxAssetCache.cpp" + "../debugCodes.cpp" +) + +target_include_directories(sandboxUtils +PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/../../include/" +) + +target_link_libraries(sandboxUtils +PRIVATE + usd + fileformatUtils +) + +set_target_properties(sandboxUtils PROPERTIES INSTALL_RPATH "${CMAKE_INSTALL_RPATH}") + + +# Installation of plugin files mimics the file structure that USD has for plugins, +# so it is easy to deploy it in a pre-existing USD build, if one chooses to do so. + +# Allow an option for deferring the path replacement to install time + +install(TARGETS sandboxUtils) \ No newline at end of file diff --git a/sandbox/src/utilities/base64url.cpp b/sandbox/src/utilities/base64url.cpp new file mode 100644 index 00000000..ddca84f7 --- /dev/null +++ b/sandbox/src/utilities/base64url.cpp @@ -0,0 +1,120 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +namespace adobe::usd::sandbox::base64url { + +namespace { + +// RFC 4648 §5 URL- and filename-safe alphabet: indices 0-63 map to the encoded character. +constexpr char kAlphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +// Map an encoded character back to its 6-bit value, or -1 if it is not in the alphabet. The +// standard-base64 characters '+' and '/' are deliberately not in the alphabet and so are rejected. +int +sextet(unsigned char c) +{ + if (c >= 'A' && c <= 'Z') + return c - 'A'; + if (c >= 'a' && c <= 'z') + return c - 'a' + 26; + if (c >= '0' && c <= '9') + return c - '0' + 52; + if (c == '-') + return 62; + if (c == '_') + return 63; + return -1; +} + +} + +std::string +encode(std::string_view bytes) +{ + std::string out; + out.reserve((bytes.size() + 2) / 3 * 4); + + // Bytes are read as unsigned so values above 127 shift correctly regardless of char signedness. + const auto* data = reinterpret_cast(bytes.data()); + const size_t n = bytes.size(); + + size_t i = 0; + for (; i + 3 <= n; i += 3) { + const unsigned b0 = data[i], b1 = data[i + 1], b2 = data[i + 2]; + out += kAlphabet[b0 >> 2]; + out += kAlphabet[((b0 & 0x03) << 4) | (b1 >> 4)]; + out += kAlphabet[((b1 & 0x0f) << 2) | (b2 >> 6)]; + out += kAlphabet[b2 & 0x3f]; + } + + // Trailing partial group: emit only the characters that carry data (padding is stripped). + if (const size_t rem = n - i; rem == 1) { + const unsigned b0 = data[i]; + out += kAlphabet[b0 >> 2]; + out += kAlphabet[(b0 & 0x03) << 4]; + } else if (rem == 2) { + const unsigned b0 = data[i], b1 = data[i + 1]; + out += kAlphabet[b0 >> 2]; + out += kAlphabet[((b0 & 0x03) << 4) | (b1 >> 4)]; + out += kAlphabet[(b1 & 0x0f) << 2]; + } + + return out; +} + +std::optional +decode(std::string_view text) +{ + // Accept padded or unpadded input: strip any trailing '=' first. A '=' anywhere else is not + // in the alphabet and is rejected below. + size_t len = text.size(); + while (len > 0 && text[len - 1] == '=') + --len; + const std::string_view s = text.substr(0, len); + + // A length of 1 (mod 4) cannot be produced by base64: there are no leftover bits for a byte. + if (s.size() % 4 == 1) + return std::nullopt; + + std::string out; + out.reserve(s.size() / 4 * 3 + 2); + + size_t i = 0; + for (; i + 4 <= s.size(); i += 4) { + const int c0 = sextet(s[i]), c1 = sextet(s[i + 1]), c2 = sextet(s[i + 2]), + c3 = sextet(s[i + 3]); + if ((c0 | c1 | c2 | c3) < 0) // any -1 sets the sign bit of the OR + return std::nullopt; + out += static_cast((c0 << 2) | (c1 >> 4)); + out += static_cast(((c1 & 0x0f) << 4) | (c2 >> 2)); + out += static_cast(((c2 & 0x03) << 6) | c3); + } + + if (const size_t rem = s.size() - i; rem == 2) { + const int c0 = sextet(s[i]), c1 = sextet(s[i + 1]); + if ((c0 | c1) < 0) + return std::nullopt; + out += static_cast((c0 << 2) | (c1 >> 4)); + } else if (rem == 3) { + const int c0 = sextet(s[i]), c1 = sextet(s[i + 1]), c2 = sextet(s[i + 2]); + if ((c0 | c1 | c2) < 0) + return std::nullopt; + out += static_cast((c0 << 2) | (c1 >> 4)); + out += static_cast(((c1 & 0x0f) << 4) | (c2 >> 2)); + } + + return out; +} + +} diff --git a/sandbox/src/utilities/quarantine.cpp b/sandbox/src/utilities/quarantine.cpp new file mode 100644 index 00000000..91301fa4 --- /dev/null +++ b/sandbox/src/utilities/quarantine.cpp @@ -0,0 +1,78 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include +#include + +#include + +using namespace PXR_NS; + +namespace adobe::usd::sandbox { + +bool +IsQuarantined(std::string_view assetPath) +{ + return assetPath.substr(0, kBadAssetScheme.size()) == kBadAssetScheme; +} + +std::string +QuarantineReference(std::string_view originalRef) +{ + // Idempotent: never nest a quarantine URI inside another on re-import or round-trip. + if (IsQuarantined(originalRef)) { + return std::string(originalRef); + } + return std::string(kBadAssetScheme) + base64url::encode(originalRef); +} + +std::optional +RevealQuarantinedReference(std::string_view badAssetUri) +{ + if (!IsQuarantined(badAssetUri)) { + return std::nullopt; + } + std::optional decoded = + base64url::decode(badAssetUri.substr(kBadAssetScheme.size())); + if (!decoded) { + return std::nullopt; + } + // A NUL byte is never legitimate in a reference; rejecting it here closes a NUL-truncation + // allow-list bypass in host re-resolve code (which may treat the decoded bytes as a C string). + if (decoded->find('\0') != std::string::npos) { + return std::nullopt; + } + return decoded; +} + +std::vector +CollectQuarantinedReferences(const SdfLayerHandle& layer) +{ + std::vector quarantined; + if (!layer) { + return quarantined; + } + + // FindAssetPaths visits both default and time-sampled (animated) asset values, so a + // quarantined reference hidden in an animated attribute is still collected. + const SdfLayerRefPtr refLayer(&(*layer)); + for (const auto& [authoredPath, resolvedPath] : FindAssetPaths(refLayer)) { + if (IsQuarantined(authoredPath)) { + quarantined.push_back(authoredPath); + } + } + return quarantined; +} + +} diff --git a/sandbox/src/utilities/sandboxAssetCache.cpp b/sandbox/src/utilities/sandboxAssetCache.cpp new file mode 100644 index 00000000..beec3ed0 --- /dev/null +++ b/sandbox/src/utilities/sandboxAssetCache.cpp @@ -0,0 +1,74 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include +#include + +namespace adobe::usd::sandbox { + +PXR_NAMESPACE_USING_DIRECTIVE; + +namespace { + +// Normalize a cache key so producer and consumer agree regardless of how their respective +// resolvers spelled the path. On Windows the import side stores keys with backslashes from +// the SdfLayer's authored asset paths (`C:\…\file.gltf[image.png]`), while the export side +// looks them up with USD-normalized forward slashes (`C:/…/file.gltf[image.png]`); without +// normalization the lookup misses and intermediate assets that only live in this cache +// (e.g. images synthesized during import) cannot be retrieved. +std::string +canonicalKey(const std::string& path) +{ + auto [outer, inner] = ArSplitPackageRelativePathInner(path); + if (inner.empty()) { + return TfNormPath(path); + } + return ArJoinPackageRelativePath(TfNormPath(outer), inner); +} + +} + +SandboxAssetCache& +SandboxAssetCache::GetInstance() +{ + static SandboxAssetCache instance; + return instance; +} + +void +SandboxAssetCache::AddImageToCache(const std::string& path, + const std::shared_ptr& asset) +{ + std::lock_guard lock(_assetCacheMutex); + _assetCache[canonicalKey(path)] = asset; +} + +void +SandboxAssetCache::RemoveImageFromCache(const std::string& path) +{ + std::lock_guard lock(_assetCacheMutex); + _assetCache.erase(canonicalKey(path)); +} + +std::shared_ptr +SandboxAssetCache::FindCachedAsset(const std::string& path) +{ + std::lock_guard lock(_assetCacheMutex); + const auto it = _assetCache.find(canonicalKey(path)); + return it != _assetCache.end() ? it->second : nullptr; +} + +// TODO: Add garbage collection. See AssetCacheSingleton for a possible implementation + +} // namespace adobe::usd::sandbox \ No newline at end of file diff --git a/sandbox/src/utilities/utilities.cpp b/sandbox/src/utilities/utilities.cpp new file mode 100644 index 00000000..ea4fc67c --- /dev/null +++ b/sandbox/src/utilities/utilities.cpp @@ -0,0 +1,246 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace adobe::usd::sandbox { + +PXR_NAMESPACE_USING_DIRECTIVE; + +bool +ConsumeBoolArg(std::map& args, const std::string& key) +{ + auto it = args.find(key); + if (it == args.end()) { + return false; + } + bool value = (it->second == "true"); + args.erase(it); + return value; +} + +std::filesystem::path +getExecutableDirectory() +{ + std::filesystem::path currentPath = pxr::ArchGetExecutablePath(); + return currentPath.parent_path(); +} + +std::string +NormalizePath(const std::string& path) +{ + std::string normalizedPath = path; + std::replace(normalizedPath.begin(), normalizedPath.end(), '\\', '/'); + return normalizedPath; +} + +std::unordered_map +FindAssetPaths(SdfLayerRefPtr layer) +{ + return FindAndModifyAssetPaths(layer, [](const std::string&, std::string&) { return false; }); +} + +std::unordered_map +FindAndModifyAssetPaths( + SdfLayerRefPtr layerToModify, + const std::function& createNewAssetName) +{ + return FindAndModifyAssetPaths(layerToModify, createNewAssetName, *layerToModify); +} + +std::unordered_map +FindAndModifyAssetPaths( + SdfLayerRefPtr layerToModify, + const std::function& createNewAssetName, + const SdfLayer& layerForResolvingAssets) +{ + // First element is the authored asset path for referencing the texture, while the second is + // the resolved asset path for finding it on disk. They may be the same + std::unordered_map assets; + + layerToModify->Traverse(SdfPath::AbsoluteRootPath(), [&](const SdfPath& path) { + if (!path.IsPropertyPath()) { + return; + } + SdfPropertySpecHandle prop = layerToModify->GetPropertyAtPath(path); + if (!prop || prop->GetTypeName() != SdfValueTypeNames->Asset) { + return; + } + + // A single asset value may appear both in the property's default and in one or more of its + // time samples (e.g. a constant animated attribute). Cache the createNewAssetName decision + // per distinct authored value so the callback fires once per value, while still rewriting + // every occurrence with the (deterministic) new name. + std::unordered_map> decisionByAuthoredPath; + + // Extract the asset path from one authored value, run it past createNewAssetName, record it + // in the result map, and (if the callback asked) write the new name back via setValue. + auto handleValue = [&](const VtValue& value, + const std::function& setValue) { + if (value.IsEmpty() || !value.IsHolding()) { + return; + } + std::string assetPath = value.UncheckedGet().GetAssetPath(); + if (assetPath.empty()) { + return; + } + + // Turn a (potentially) relative asset path into an absolute resolved path. Use the + // source layer so the path is relative to the location of the original asset + std::string normalizedPath = NormalizePath(assetPath); + std::string resolvedAssetPath = + layerForResolvingAssets.ComputeAbsolutePath(normalizedPath); + + auto [it, inserted] = + decisionByAuthoredPath.try_emplace(assetPath, false, std::string()); + if (inserted) { + it->second.first = createNewAssetName(assetPath, it->second.second); + } + const auto& [shouldRewrite, newAssetName] = it->second; + + std::string effectivePath = assetPath; + if (shouldRewrite) { + setValue(SdfAssetPath(newAssetName)); + effectivePath = newAssetName; + } + + // Multiple nodes may refer to the same asset, so we have to ensure it's only added once + if (assets.find(effectivePath) == assets.end()) { + assets.insert({ effectivePath, resolvedAssetPath }); + } + }; + + // Default value. + handleValue(prop->GetDefaultValue(), + [&](const SdfAssetPath& v) { prop->SetDefaultValue(VtValue(v)); }); + + // Time-sampled values: a worker can hide a reference in an animated asset attribute, which + // a default-only scrub would miss. Scoped to asset-typed attributes (the type check above), + // so non-asset and non-animated attributes pay nothing here. + for (double time : layerToModify->ListTimeSamplesForPath(path)) { + VtValue sampleValue; + if (layerToModify->QueryTimeSample(path, time, &sampleValue)) { + handleValue(sampleValue, [&](const SdfAssetPath& v) { + layerToModify->SetTimeSample(path, time, VtValue(v)); + }); + } + } + }); + return assets; +} + +std::unordered_map> +GetArAssets(const std::unordered_map& assets) +{ + // Pair of asset path and asset data + std::unordered_map> arAssets; + arAssets.reserve(assets.size()); + + for (const auto& [authoredPath, resolvedPath] : assets) { + if (authoredPath == resolvedPath) { + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, "Found asset: %s\n", authoredPath.c_str()); + } else { + TF_DEBUG_MSG(FILE_FORMAT_SANDBOXPROXY, + "Found asset: %s (full path: %s)\n", + authoredPath.c_str(), + resolvedPath.c_str()); + } + + ArResolvedPath resolvedAssetPath = ArResolvedPath(resolvedPath); + std::shared_ptr assetData = ArGetResolver().OpenAsset(resolvedAssetPath); + + if (assetData) { + arAssets[authoredPath] = assetData; + } else { + TF_WARN("Could not resolve asset for path %s\n", resolvedPath.c_str()); + } + } + return arAssets; +} + +std::string +BuildNewPluginPath(const std::string& currentPxrPluginPath, + const std::string& proxyPluginPath, + const std::string& unsafePluginRoot) +{ +#if SANDBOX_IS_WINDOWS + const std::string separator = ";"; +#else + const std::string separator = ":"; +#endif + + if (currentPxrPluginPath.empty()) { + return unsafePluginRoot; + } + + // The current plugin couldn't be located, so it cannot be properly filtered out of + // PXR_PLUGINPATH_NAME. For that reason, to ensure the sandbox doesn't find the proxy plugin, + // the environment variable must be completely cleared (and replaced with the sandboxed plugin + // location). + if (proxyPluginPath.empty()) { + TF_WARN("(HOST) No proxy plugin location found, PXR_PLUGINPATH_NAME environment variable " + "cannot be filtered. The sandboxed process will be launched with a " + "PXR_PLUGINPATH_NAME completely cleared and reset."); + return unsafePluginRoot; + } + + std::error_code ec; + const std::filesystem::path canonicalProxyPath = + std::filesystem::weakly_canonical(proxyPluginPath, ec); + if (ec) { + TF_WARN("(HOST) Could not canonicalize proxy plugin path '%s': %s. " + "PXR_PLUGINPATH_NAME cannot be filtered. The sandboxed process will be launched " + "with a PXR_PLUGINPATH_NAME completely cleared and reset.", + proxyPluginPath.c_str(), + ec.message().c_str()); + return unsafePluginRoot; + } + + std::vector paths = TfStringSplit(currentPxrPluginPath, separator); + bool found = false; + for (auto& p : paths) { + std::error_code entryEc; + // Error codes occur rarely and in unusual circumstances (non-existant paths are handled + // properly). If one occurs, it likely isn't the path used for finding the proxy plugin. + if (std::filesystem::weakly_canonical(p, entryEc) == canonicalProxyPath && !entryEc) { + p = unsafePluginRoot; + found = true; + } + } + + if (!found) { + paths.insert(paths.begin(), unsafePluginRoot); + } + + return TfStringJoin(paths, separator.c_str()); +} + +} diff --git a/sandbox/tests/CMakeLists.txt b/sandbox/tests/CMakeLists.txt new file mode 100644 index 00000000..b1ab1148 --- /dev/null +++ b/sandbox/tests/CMakeLists.txt @@ -0,0 +1,128 @@ +find_package(GTest REQUIRED) +include(GoogleTest) +FIND_PACKAGE(Threads REQUIRED) + +set(TARGET_NAME sandboxUtilsTests) + +add_executable(sandboxUtilsTests testPluginPath.cpp main.cpp) + +target_include_directories(sandboxUtilsTests + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../include" + "${PXR_INCLUDE_DIRS}" +) + +target_link_libraries(sandboxUtilsTests PRIVATE + GTest::gtest + sandboxUtils + sandboxProtocol + tf + sdf + usd + arch +) +add_test(NAME sandboxUtilsTests COMMAND sandboxUtilsTests) + +# testHardening.cpp is skipped from the public mirror sync (internal hardening +# validation, not part of the public test harness), so guard this target: a +# CMakeLists.txt shared with the public repo must still configure cleanly +# there even though the source file it references doesn't exist. +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/testHardening.cpp") + add_executable(hardeningTests testHardening.cpp) + + target_include_directories(hardeningTests + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../include" + "${PXR_INCLUDE_DIRS}" + ) + + # Windows: testHardening.cpp provides its own main() (needed to intercept + # --apply-restrictions-and-report before GoogleTest runs), so link gtest + # (not gtest_main). On all other platforms, gtest_main supplies main(). + if(WIN32) + target_link_libraries(hardeningTests PRIVATE + GTest::gtest + sandboxHardening + ) + else() + target_link_libraries(hardeningTests PRIVATE + GTest::gtest_main + sandboxHardening + ) + endif() + + add_test(NAME hardeningTests COMMAND hardeningTests) +endif() + +add_executable(protocolTests testProtocol.cpp) + +target_include_directories(protocolTests + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../include" + "${PXR_INCLUDE_DIRS}" +) + +target_link_libraries(protocolTests PRIVATE + GTest::gtest_main + sandboxProtocol + usdSerialization +) + +add_test(NAME protocolTests COMMAND protocolTests) + +add_executable(base64UrlTests testBase64url.cpp) + +target_include_directories(base64UrlTests + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../include" + "${PXR_INCLUDE_DIRS}" +) + +target_link_libraries(base64UrlTests PRIVATE + GTest::gtest_main + sandboxUtils +) + +add_test(NAME base64UrlTests COMMAND base64UrlTests) + +add_executable(utilitiesTests testUtilities.cpp) + +target_include_directories(utilitiesTests + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../include" + "${PXR_INCLUDE_DIRS}" +) + +target_link_libraries(utilitiesTests PRIVATE + GTest::gtest_main + sandboxUtils + sdf + usd + tf +) + +add_test(NAME utilitiesTests COMMAND utilitiesTests) + +# The BadAsset:// scheme is discovered through the USD plugin registry (PXR_PLUGINPATH_NAME), +# so this executable does not link usdInMemResolver directly -- linking the build-tree resolver +# dylib while the registry loads the installed copy would double-register the resolver types. +# The quarantine helpers under test live in sandboxUtils (a plain utility lib, not a Plug-loaded +# resolver plugin), so linking it here is safe. +add_executable(badAssetResolverTests testBadAssetResolver.cpp) + +target_include_directories(badAssetResolverTests + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../include" + "${PXR_INCLUDE_DIRS}" +) + +target_link_libraries(badAssetResolverTests PRIVATE + GTest::gtest_main + sandboxUtils + ar + usd + sdf + tf +) + +add_test(NAME badAssetResolverTests COMMAND badAssetResolverTests) diff --git a/sandbox/tests/main.cpp b/sandbox/tests/main.cpp new file mode 100644 index 00000000..aff132ae --- /dev/null +++ b/sandbox/tests/main.cpp @@ -0,0 +1,22 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +int +main(int argc, char** argv) +{ + setlocale(LC_NUMERIC, "C"); + testing::InitGoogleTest(&argc, argv); + int res = RUN_ALL_TESTS(); + return res; +} diff --git a/sandbox/tests/testBadAssetResolver.cpp b/sandbox/tests/testBadAssetResolver.cpp new file mode 100644 index 00000000..8bf341ba --- /dev/null +++ b/sandbox/tests/testBadAssetResolver.cpp @@ -0,0 +1,131 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +using adobe::usd::sandbox::CollectQuarantinedReferences; +using adobe::usd::sandbox::IsQuarantined; +using adobe::usd::sandbox::QuarantineReference; +using adobe::usd::sandbox::RevealQuarantinedReference; + +// The BadAsset:// scheme resolves to an inert path: Resolve() echoes the URI unchanged (so it is a +// stable, opaque handle), but OpenAsset() yields nothing. This is what makes a quarantined +// reference safe to carry in a stage without ever granting access to the underlying bytes. +TEST(BadAssetResolver, ResolvesInert) +{ + const std::string uri = "BadAsset://L2V0Yy9wYXNzd2Q"; // encodes /etc/passwd + auto resolved = ArGetResolver().Resolve(uri); + // The BadAsset resolver is discovered through the USD plugin registry (PXR_PLUGINPATH_NAME + // pointed at the sandbox proxy plugins). The package-install / CI test env sets that up; a bare + // developer `ctest` after `cmake && make` does not, so skip rather than report a false failure. + // The quarantine-helper tests below need no plugin and always run. + if (resolved.GetPathString() != uri) { + GTEST_SKIP() + << "BadAsset resolver not registered (PXR_PLUGINPATH_NAME not pointed at the " + "sandbox proxy plugins); validated under the package-install / CI test env."; + } + EXPECT_EQ(resolved.GetPathString(), uri); + EXPECT_EQ(ArGetResolver().OpenAsset(resolved), nullptr); +} + +TEST(Quarantine, RoundTripThroughReveal) +{ + const std::string q = QuarantineReference("/etc/passwd"); + EXPECT_EQ(q, "BadAsset://L2V0Yy9wYXNzd2Q"); + EXPECT_TRUE(IsQuarantined(q)); + ASSERT_TRUE(RevealQuarantinedReference(q).has_value()); + EXPECT_EQ(*RevealQuarantinedReference(q), "/etc/passwd"); +} + +TEST(Quarantine, RejectsNonQuarantine) +{ + EXPECT_FALSE(RevealQuarantinedReference("/etc/passwd").has_value()); + EXPECT_FALSE(IsQuarantined("model.fbx[t.png]")); +} + +TEST(Quarantine, RejectsEmbeddedNulOnReveal) +{ + const std::string q = QuarantineReference(std::string("/allowed/x\0/../etc/passwd", 25)); + EXPECT_FALSE(RevealQuarantinedReference(q).has_value()); // NUL-truncation bypass closed +} + +TEST(Quarantine, RefusesDoubleWrap) +{ + const std::string once = QuarantineReference("/etc/passwd"); + EXPECT_EQ(QuarantineReference(once), once); // no nesting on re-import/round-trip +} + +TEST(Quarantine, HandlesEmptyAndShortReferences) +{ + // An empty reference quarantines to the bare scheme (empty payload) and reveals to empty. + const std::string qEmpty = QuarantineReference(""); + EXPECT_EQ(qEmpty, "BadAsset://"); + EXPECT_TRUE(IsQuarantined(qEmpty)); + ASSERT_TRUE(RevealQuarantinedReference(qEmpty).has_value()); + EXPECT_EQ(*RevealQuarantinedReference(qEmpty), ""); + + // 1- and 2-byte references round-trip through quarantine + reveal. + for (const std::string& ref : { std::string("x"), std::string("ab") }) { + const std::string q = QuarantineReference(ref); + EXPECT_TRUE(IsQuarantined(q)); + ASSERT_TRUE(RevealQuarantinedReference(q).has_value()); + EXPECT_EQ(*RevealQuarantinedReference(q), ref); + } +} + +TEST(Quarantine, RejectsMalformedShortPayload) +{ + // The encoder never emits a length-1 (mod 4) payload, so a 1-character payload is malformed; + // reveal must reject it rather than return garbage. + EXPECT_FALSE(RevealQuarantinedReference("BadAsset://A").has_value()); + // The bare scheme (empty payload) is well-formed and reveals to the empty string. + EXPECT_TRUE(RevealQuarantinedReference("BadAsset://").has_value()); +} + +TEST(Quarantine, CollectsFromLayerIncludingTimeSamples) +{ + SdfLayerRefPtr layer = SdfLayer::CreateAnonymous(".usda"); + SdfPrimSpecHandle prim = SdfPrimSpec::New(layer, "Prim", SdfSpecifierDef); + + // One quarantined value in a default, another only in a time sample. + const std::string qDefault = QuarantineReference("/etc/passwd"); + const std::string qSample = QuarantineReference("../../secret"); + + SdfAttributeSpecHandle defAttr = + SdfAttributeSpec::New(prim, "defTex", SdfValueTypeNames->Asset); + defAttr->SetDefaultValue(VtValue(SdfAssetPath(qDefault))); + + SdfAttributeSpecHandle tsAttr = SdfAttributeSpec::New(prim, "tsTex", SdfValueTypeNames->Asset); + layer->SetTimeSample(tsAttr->GetPath(), 0.0, VtValue(SdfAssetPath(qSample))); + + // A non-quarantined asset value must not be collected. + SdfAttributeSpecHandle okAttr = SdfAttributeSpec::New(prim, "okTex", SdfValueTypeNames->Asset); + okAttr->SetDefaultValue(VtValue(SdfAssetPath("model.fbx[texture.png]"))); + + const std::vector collected = CollectQuarantinedReferences(layer); + EXPECT_EQ(collected.size(), 2u); + EXPECT_NE(std::find(collected.begin(), collected.end(), qDefault), collected.end()); + EXPECT_NE(std::find(collected.begin(), collected.end(), qSample), collected.end()); +} diff --git a/sandbox/tests/testBase64url.cpp b/sandbox/tests/testBase64url.cpp new file mode 100644 index 00000000..7446d2ec --- /dev/null +++ b/sandbox/tests/testBase64url.cpp @@ -0,0 +1,68 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include + +using adobe::usd::sandbox::base64url::decode; +using adobe::usd::sandbox::base64url::encode; + +TEST(Base64Url, RoundTripsArbitraryBytes) +{ + for (const std::string& s : { + std::string("/etc/passwd"), + std::string("../../../secret"), + std::string("http://169.254.169.254/latest/meta-data"), + std::string("\\\\host\\share\\x"), + std::string("\0\x01\xff embedded", 12), + // High bytes (>127) are where a signed-char encoder bug shows up -- load-bearing. + std::string("/Users/Jose\xCC\x81/textures/cafe\xCC\x81.png"), // accented Latin (NFD) + std::string( + "/\xE8\xB5\x84\xE4\xBA\xA7/\xE7\xBA\xB9\xE7\x90\x86.png"), // CJK 资产/纹理.png + std::string("my textures/rock (1).png"), // spaces + parens + }) { + const std::string enc = encode(s); + EXPECT_EQ(enc.find_first_of("+/="), std::string::npos); // url-safe, unpadded + ASSERT_TRUE(decode(enc).has_value()); + EXPECT_EQ(*decode(enc), s); + } +} + +TEST(Base64Url, RejectsInvalidInput) +{ + EXPECT_FALSE(decode("!!!!").has_value()); // non-alphabet + EXPECT_FALSE(decode("A").has_value()); // impossible length (1 mod 4) +} + +TEST(Base64Url, KnownVector) +{ + EXPECT_EQ(encode("/etc/passwd"), "L2V0Yy9wYXNzd2Q"); +} + +TEST(Base64Url, EmptyAndShortRoundTrip) +{ + // Empty input encodes to empty and decodes back to empty (not nullopt). + EXPECT_EQ(encode(""), ""); + ASSERT_TRUE(decode("").has_value()); + EXPECT_EQ(*decode(""), ""); + + // 1-, 2-, and 3-byte inputs round-trip (the partial-group tails of the encoder). + for (const std::string& s : { std::string("a"), std::string("ab"), std::string("abc") }) { + const std::string enc = encode(s); + EXPECT_EQ(enc.find_first_of("+/="), std::string::npos); + ASSERT_TRUE(decode(enc).has_value()) << "failed to decode: " << enc; + EXPECT_EQ(*decode(enc), s); + } +} diff --git a/sandbox/tests/testPluginPath.cpp b/sandbox/tests/testPluginPath.cpp new file mode 100644 index 00000000..68415fd7 --- /dev/null +++ b/sandbox/tests/testPluginPath.cpp @@ -0,0 +1,135 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include +#include + +#include + +#include +#include + +namespace { +// Mirror the platform separator used inside BuildNewPluginPath so test expectations match +// exactly on both Windows and POSIX builds. +#if SANDBOX_IS_WINDOWS +const std::string SEP = ";"; +#else +const std::string SEP = ":"; +#endif +} + +using adobe::usd::sandbox::BuildNewPluginPath; +using adobe::usd::sandbox::ConsumeBoolArg; +// params: currentPxrPluginPath, proxyPluginPath, unsafePluginRoot + +// When the current PXR_PLUGINPATH_NAME is empty, return unsafePluginRoot directly. +TEST(BuildNewPluginPath, EmptyCurrentPath) +{ + ASSERT_EQ(BuildNewPluginPath("", "/proxy/dir", "/unsafe/root"), "/unsafe/root"); +} + +// When proxyPluginPath is empty we don't know how the proxy plugin was referenced; to be safe the +// whole variable will be replaced entirely to prevent the sandbox from finding the proxy plugin. +TEST(BuildNewPluginPath, EmptyProxyPath) +{ + const std::string current = "/other/a" + SEP + "/other/b"; + ASSERT_EQ(BuildNewPluginPath(current, "", "/unsafe/root"), "/unsafe/root"); +} + +// Current path contains only the proxy entry — result is just unsafePluginRoot. +TEST(BuildNewPluginPath, ProxyFoundSingleEntry) +{ + ASSERT_EQ(BuildNewPluginPath("/proxy/dir", "/proxy/dir", "/unsafe/root"), "/unsafe/root"); +} + +// Proxy is the first of multiple entries — replaced in-place, order preserved. +TEST(BuildNewPluginPath, ProxyFoundAtBeginning) +{ + const std::string current = "/proxy/dir" + SEP + "/other/a" + SEP + "/other/b"; + const std::string expected = "/unsafe/root" + SEP + "/other/a" + SEP + "/other/b"; + ASSERT_EQ(BuildNewPluginPath(current, "/proxy/dir", "/unsafe/root"), expected); +} + +// Proxy is in the middle of three entries — replaced in-place, flanking entries unchanged. +TEST(BuildNewPluginPath, ProxyFoundInMiddle) +{ + const std::string current = "/other/a" + SEP + "/proxy/dir" + SEP + "/other/b"; + const std::string expected = "/other/a" + SEP + "/unsafe/root" + SEP + "/other/b"; + ASSERT_EQ(BuildNewPluginPath(current, "/proxy/dir", "/unsafe/root"), expected); +} + +// Proxy is the last of multiple entries — replaced in-place. +TEST(BuildNewPluginPath, ProxyFoundAtEnd) +{ + const std::string current = "/other/a" + SEP + "/other/b" + SEP + "/proxy/dir"; + const std::string expected = "/other/a" + SEP + "/other/b" + SEP + "/unsafe/root"; + ASSERT_EQ(BuildNewPluginPath(current, "/proxy/dir", "/unsafe/root"), expected); +} + +// Proxy appears twice in the path — both occurrences are replaced. +TEST(BuildNewPluginPath, ProxyDuplicateEntries) +{ + const std::string current = "/proxy/dir" + SEP + "/other/a" + SEP + "/proxy/dir"; + const std::string expected = "/unsafe/root" + SEP + "/other/a" + SEP + "/unsafe/root"; + ASSERT_EQ(BuildNewPluginPath(current, "/proxy/dir", "/unsafe/root"), expected); +} + +// Current path has a single entry that does not match the proxy — unsafePluginRoot is prepended. +TEST(BuildNewPluginPath, ProxyNotFoundSingleEntry) +{ + const std::string expected = "/unsafe/root" + SEP + "/other/a"; + ASSERT_EQ(BuildNewPluginPath("/other/a", "/proxy/dir", "/unsafe/root"), expected); +} + +// Multiple existing entries, none matching the proxy — unsafePluginRoot prepended, rest preserved. +TEST(BuildNewPluginPath, ProxyNotFoundMultipleEntries) +{ + const std::string current = "/other/a" + SEP + "/other/b" + SEP + "/other/c"; + const std::string expected = + "/unsafe/root" + SEP + "/other/a" + SEP + "/other/b" + SEP + "/other/c"; + ASSERT_EQ(BuildNewPluginPath(current, "/proxy/dir", "/unsafe/root"), expected); +} + +// Verify that the platform-specific separator character is used to join the result. This test +// constructs input using SEP and checks the output uses SEP as well, locking in the behavior on +// both Windows (";") and POSIX (":") builds. +TEST(BuildNewPluginPath, PlatformSeparatorUsed) +{ + const std::string current = "/entry/a" + SEP + "/proxy/dir" + SEP + "/entry/b"; + const std::string result = BuildNewPluginPath(current, "/proxy/dir", "/unsafe/root"); + + // The result must contain SEP and must NOT contain the other platform's separator. + ASSERT_NE(result.find(SEP), std::string::npos); +#if SANDBOX_IS_WINDOWS + ASSERT_EQ(result.find(':'), std::string::npos); +#else + ASSERT_EQ(result.find(';'), std::string::npos); +#endif +} + +// Only the exact value "true" is truthy; the key is always erased so a consumed host-only argument +// is not forwarded to the sandboxed worker, while unrelated entries are left intact. +TEST(ConsumeBoolArg, ParsesAndErases) +{ + std::map args = { + { "yes", "true" }, { "no", "false" }, { "caps", "TRUE" }, { "keep", "x" } + }; + EXPECT_TRUE(ConsumeBoolArg(args, "yes")); + EXPECT_FALSE(ConsumeBoolArg(args, "no")); + EXPECT_FALSE(ConsumeBoolArg(args, "caps")); // only lowercase "true" is truthy + EXPECT_FALSE(ConsumeBoolArg(args, "absent")); // missing key yields false + EXPECT_EQ(args.count("yes"), 0u); // consumed keys are erased + EXPECT_EQ(args.count("no"), 0u); + EXPECT_EQ(args.count("caps"), 0u); + EXPECT_EQ(args.count("keep"), 1u); // unrelated keys remain +} diff --git a/sandbox/tests/testProtocol.cpp b/sandbox/tests/testProtocol.cpp new file mode 100644 index 00000000..e508da64 --- /dev/null +++ b/sandbox/tests/testProtocol.cpp @@ -0,0 +1,257 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include +#include +#include + +#include +#include + +#include + +#include + +using namespace adobe::usd; +using namespace adobe::usd::sandbox; +using namespace adobe::usd::serialization; + +TEST(ProtocolMessageTests, FileFormatArgsMessageRoundTrip) +{ + FileFormatArgsMessage original; + original.args = { + { "format", "fbx" }, + { "quality", "high" }, + { "importMaterials", "true" }, + }; + + BufferWriter writer; + original.WriteTo(writer); + auto data = writer.Finish(); + + BufferReader reader(data); + FileFormatArgsMessage decoded = FileFormatArgsMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(decoded.args, original.args); +} + +TEST(ProtocolMessageTests, FileFormatArgsMessageEmpty) +{ + FileFormatArgsMessage original; + + BufferWriter writer; + original.WriteTo(writer); + auto data = writer.Finish(); + + BufferReader reader(data); + FileFormatArgsMessage decoded = FileFormatArgsMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_TRUE(decoded.args.empty()); +} + +TEST(ProtocolMessageTests, AssetSizeMessageRoundTrip) +{ + AssetSizeMessage original; + original.assetsSize = 1048576; // 1 MB + + BufferWriter writer; + original.WriteTo(writer); + auto data = writer.Finish(); + + BufferReader reader(data); + AssetSizeMessage decoded = AssetSizeMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(decoded.assetsSize, original.assetsSize); +} + +TEST(ProtocolMessageTests, AssetSizeMessageZero) +{ + AssetSizeMessage original; + original.assetsSize = 0; + + BufferWriter writer; + original.WriteTo(writer); + auto data = writer.Finish(); + + BufferReader reader(data); + AssetSizeMessage decoded = AssetSizeMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(decoded.assetsSize, 0u); +} + +TEST(ProtocolMessageTests, SharedMemoryInfoMessageRoundTrip) +{ + SharedMemoryInfoMessage original; + original.name = "/UsdSHM_42"; + original.size = 2097152; // 2 MB + + BufferWriter writer; + original.WriteTo(writer); + auto data = writer.Finish(); + + BufferReader reader(data); + SharedMemoryInfoMessage decoded = SharedMemoryInfoMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(decoded.name, original.name); + EXPECT_EQ(decoded.size, original.size); +} + +TEST(ProtocolMessageTests, SharedMemoryInfoMessageWindowsName) +{ + SharedMemoryInfoMessage original; + original.name = "Local\\UsdSharedMemory_1"; + original.size = 4096; + + BufferWriter writer; + original.WriteTo(writer); + auto data = writer.Finish(); + + BufferReader reader(data); + SharedMemoryInfoMessage decoded = SharedMemoryInfoMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(decoded.name, original.name); + EXPECT_EQ(decoded.size, original.size); +} + +TEST(ProtocolMessageTests, AllMessagesSequential) +{ + // Simulate a full import protocol message sequence + std::vector argsData, sizeData, shmData; + + { + FileFormatArgsMessage msg; + msg.args = { { "format", "gltf" }, { "textureMode", "embedded" } }; + BufferWriter writer; + msg.WriteTo(writer); + argsData = writer.Finish(); + } + + { + AssetSizeMessage msg; + msg.assetsSize = 500000; + BufferWriter writer; + msg.WriteTo(writer); + sizeData = writer.Finish(); + } + + { + SharedMemoryInfoMessage msg; + msg.name = "/UsdSHM_test"; + msg.size = 500000; + BufferWriter writer; + msg.WriteTo(writer); + shmData = writer.Finish(); + } + + // Verify each can be independently decoded + { + BufferReader reader(argsData); + auto msg = FileFormatArgsMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(msg.args["format"], "gltf"); + EXPECT_EQ(msg.args["textureMode"], "embedded"); + } + + { + BufferReader reader(sizeData); + auto msg = AssetSizeMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(msg.assetsSize, 500000u); + } + + { + BufferReader reader(shmData); + auto msg = SharedMemoryInfoMessage::ReadFrom(reader); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(msg.name, "/UsdSHM_test"); + EXPECT_EQ(msg.size, 500000u); + } +} + +TEST(MessageIOTests, RoundTripThroughPipe) +{ + ipc::PipePair pipe; + ASSERT_TRUE(ipc::CreatePipePair(pipe)); + + std::vector payload = { 1, 2, 3, 4, 5 }; + EXPECT_TRUE(WriteMessageToPipe(pipe.writeEnd, payload)); + + std::vector received; + EXPECT_TRUE(ReadMessageFromPipe(pipe.readEnd, received)); + EXPECT_EQ(received, payload); +} + +TEST(MessageIOTests, OversizedDeclaredSizeRejectedWithoutReadingBody) +{ + ipc::PipePair pipe; + ASSERT_TRUE(ipc::CreatePipePair(pipe)); + + // Write ONLY a 4-byte length prefix declaring a huge body, and no body at all. + // ReadMessageFromPipe must reject after reading the prefix, without blocking on / allocating + // the body. + uint32_t hugeSize = kMaxMessageSize + 1; + ASSERT_TRUE(pipe.writeEnd.Write(&hugeSize, sizeof(hugeSize))); + pipe.writeEnd.Close(); // ensure no body is forthcoming + + std::vector received; + EXPECT_FALSE(ReadMessageFromPipe(pipe.readEnd, received)); + EXPECT_TRUE(received.empty()); +} + +TEST(MessageIOTests, ZeroLengthRejected) +{ + ipc::PipePair pipe; + ASSERT_TRUE(ipc::CreatePipePair(pipe)); + + uint32_t zero = 0; + ASSERT_TRUE(pipe.writeEnd.Write(&zero, sizeof(zero))); + pipe.writeEnd.Close(); + + std::vector received; + EXPECT_FALSE(ReadMessageFromPipe(pipe.readEnd, received)); +} + +TEST(HostProtocolStateTests, AnnounceBeforeCreateIsRejected) +{ + HostProtocol host; + // Fresh protocol is in Initialized; AnnounceSharedMemory requires ShmCreated. + // A TF_CODING_ERROR is expected; suppress its abort and assert graceful failure. + pxr::TfErrorMark mark; + EXPECT_FALSE(host.AnnounceSharedMemory()); + EXPECT_FALSE(mark.IsClean()) << "expected a coding error to be posted"; + EXPECT_EQ(host.GetState(), HostState::Initialized); + mark.Clear(); +} + +TEST(HostProtocolStateTests, CreateSharedMemoryRejectedInInitializedState) +{ + HostProtocol host; + pxr::TfErrorMark mark; + EXPECT_FALSE(host.CreateSharedMemory("/UsdSHM_test", 4096)); + EXPECT_FALSE(mark.IsClean()) << "expected a coding error to be posted"; + EXPECT_EQ(host.GetState(), HostState::Initialized); + mark.Clear(); +} + +// Sizes up to the cap (4GB - 1) are accepted; above it they are rejected unless the caller opts +// into large assets. 5 GiB is only a number here — the cap is checked before any allocation, so +// the over-cap cases allocate nothing. (The over-cap cases intentionally emit a TF_WARN.) +TEST(ValidateReportedAssetSize, CapAndOverride) +{ + constexpr size_t fiveGiB = static_cast(5) * 1024 * 1024 * 1024; + EXPECT_TRUE(ValidateReportedAssetSize(1024, false)); + EXPECT_TRUE(ValidateReportedAssetSize(kMaxSharedMemorySize, false)); // boundary accepted + EXPECT_FALSE(ValidateReportedAssetSize(kMaxSharedMemorySize + 1, false)); // just over: reject + EXPECT_FALSE(ValidateReportedAssetSize(fiveGiB, false)); + EXPECT_TRUE(ValidateReportedAssetSize(fiveGiB, true)); // override lifts cap + EXPECT_TRUE(ValidateReportedAssetSize(1024, true)); +} diff --git a/sandbox/tests/testUtilities.cpp b/sandbox/tests/testUtilities.cpp new file mode 100644 index 00000000..084250cf --- /dev/null +++ b/sandbox/tests/testUtilities.cpp @@ -0,0 +1,81 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +#include +#include +#include + +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE +using adobe::usd::sandbox::FindAndModifyAssetPaths; + +namespace { + +// Rewrite every visited asset value to "InMemory://" and record what was visited. +auto +makeRecordingRewriter(std::unordered_map& visited) +{ + return [&visited](const std::string& authored, std::string& newName) { + visited[authored] = authored; + newName = "InMemory://" + authored; + return true; + }; +} + +} + +// A subverted worker can hide a reference in a time-sampled (animated) asset attribute that has no +// default value. The traversal must still visit that value and be able to rewrite it in place. +TEST(FindAndModifyAssetPaths, VisitsAndRewritesTimeSampledAssetValueWithNoDefault) +{ + SdfLayerRefPtr layer = SdfLayer::CreateAnonymous(".usda"); + SdfPrimSpecHandle prim = SdfPrimSpec::New(layer, "Prim", SdfSpecifierDef); + SdfAttributeSpecHandle attr = SdfAttributeSpec::New(prim, "tex", SdfValueTypeNames->Asset); + const SdfPath attrPath = attr->GetPath(); + + // The reference lives ONLY in a time sample, never the default value. + layer->SetTimeSample(attrPath, 0.0, VtValue(SdfAssetPath("hidden.png"))); + + std::unordered_map visited; + FindAndModifyAssetPaths(layer, makeRecordingRewriter(visited)); + + // Visited... + ASSERT_EQ(visited.count("hidden.png"), 1u); + + // ...and rewritten in the time sample. + VtValue sample; + ASSERT_TRUE(layer->QueryTimeSample(attrPath, 0.0, &sample)); + ASSERT_TRUE(sample.IsHolding()); + EXPECT_EQ(sample.UncheckedGet().GetAssetPath(), "InMemory://hidden.png"); +} + +// Regression lock: the pre-existing default-value path keeps working unchanged. +TEST(FindAndModifyAssetPaths, StillVisitsAndRewritesDefaultAssetValue) +{ + SdfLayerRefPtr layer = SdfLayer::CreateAnonymous(".usda"); + SdfPrimSpecHandle prim = SdfPrimSpec::New(layer, "Prim", SdfSpecifierDef); + SdfAttributeSpecHandle attr = SdfAttributeSpec::New(prim, "tex", SdfValueTypeNames->Asset); + attr->SetDefaultValue(VtValue(SdfAssetPath("model.png"))); + + std::unordered_map visited; + FindAndModifyAssetPaths(layer, makeRecordingRewriter(visited)); + + ASSERT_EQ(visited.count("model.png"), 1u); + EXPECT_EQ(attr->GetDefaultValue().UncheckedGet().GetAssetPath(), + "InMemory://model.png"); +} diff --git a/sbsar/CMakeLists.txt b/sbsar/CMakeLists.txt index 2297e75c..fa3baf20 100644 --- a/sbsar/CMakeLists.txt +++ b/sbsar/CMakeLists.txt @@ -2,7 +2,7 @@ option(substance_DIR "Directory for external substance engine location" "") -option(USDSBSAR_ENABLE_INSTALL "Enable installation of plugin artifacts" ON) +cmake_dependent_option(USDSBSAR_ENABLE_INSTALL "Enable installation of plugin artifacts" ON "USD_FILEFORMATS_ENABLE_INSTALL" OFF) # Substance Engine options option(USDSBSAR_ENABLE_VULKAN "Enable Vulkan as backend for Substance Engine" OFF) # Plugin features options @@ -33,10 +33,16 @@ if(WIN32) # List d3d11 before ogl3 to give preference to the D3D11 engine set(USDSBSAR_DEFAULT_SUBSTANCE_ENGINES d3d11_blend ogl3_blend sse2_blend) elseif(USDSBSAR_BUILD_APPLE_SILICON) - set(USDSBSAR_DEFAULT_SUBSTANCE_ENGINES mtl_blend ogl3_blend neon_blend) + # cmake/substance_engine.cmake installs cpu_blend instead of neon_blend when + # the resolved substance SDK package is a universal build that doesn't ship + # a neon_blend variant. List it last so it's only used as a fallback. + set(USDSBSAR_DEFAULT_SUBSTANCE_ENGINES mtl_blend ogl3_blend neon_blend cpu_blend) else() # Linux and Intel Mac - set(USDSBSAR_DEFAULT_SUBSTANCE_ENGINES mtl_blend ogl3_blend sse2_blend) + # cmake/substance_engine.cmake installs cpu_blend instead of sse2_blend when + # the resolved substance SDK package is a universal build that doesn't ship + # an sse2_blend variant. List it last so it's only used as a fallback. + set(USDSBSAR_DEFAULT_SUBSTANCE_ENGINES mtl_blend ogl3_blend sse2_blend cpu_blend) endif() if(USDSBSAR_ENABLE_VULKAN) @@ -84,17 +90,14 @@ endif() find_package(ZLIB REQUIRED) -# The engine has a default if this is not set -# Manually setting this for arm64 mac os as -# the default was not being set correctly -if(CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64") - set(SUBSTANCE_FRAMEWORK_ENGINE_VARIANT neon_blend) -endif() - include(substance_engine) add_subdirectory(src) +# Pass this list from the src/CMakeLists.txt to the parent scope +set(SBSAR_EXT_LIST ${SBSAR_EXT_LIST} PARENT_SCOPE) + if(USD_FILEFORMATS_BUILD_TESTS) add_subdirectory(test) -endif(USD_FILEFORMATS_BUILD_TESTS) +endif() +fileformats_register_plugin("usdSbsar") diff --git a/sbsar/README.md b/sbsar/README.md index 28c31301..fd3d45a2 100644 --- a/sbsar/README.md +++ b/sbsar/README.md @@ -13,7 +13,7 @@ appropriate textures. ## Requirements - USD v23.08 or further (Note: an issue was found with v24.03) -- Substance engine v9.1.12 or further +- Substance engine v9.4.1 or further # Supported Features - Presets @@ -153,8 +153,8 @@ def DomeLight "SkyDome" ( **Error Handling:** If the SBSAR file doesn't contain graphs of the requested type, the import will fail with a clear error message: ``` -SBSAR package 'wood.sbsar' does not contain any light/environment graphs. -Package contains: 3 material graph(s). +SBSAR package 'wood.sbsar' does not contain any light/environment graphs. +Package contains: 3 material graph(s). This SBSAR file cannot be used in this context. ``` diff --git a/sbsar/data/DoubleCardBoard.usda b/sbsar/data/DoubleCardBoard.usda deleted file mode 100644 index f2526a5a..00000000 --- a/sbsar/data/DoubleCardBoard.usda +++ /dev/null @@ -1,70 +0,0 @@ -#usda 1.0 -( - defaultPrim = "World" - metersPerUnit = 0.009999999776482582 - timeCodesPerSecond = 24 - upAxis = "Y" - subLayers = [@./sbsar/DoubleCardBoard.sbsar@] -) -def Xform "World" -{ - def Xform "MatBall0" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - double3 xformOp:translate = (-110, 0, 0) - over Material "Material" ( - prepend references= - # Override presets and resolution - variants = { - string preset = "Torn" - string resolution = "res1024x1024" - } - ) - { - # Adding custom parameters, note that these are - # stronger than parameters set through presets - float procedural_sbsar:tearing = 0.4 - float3 procedural_sbsar:cardboard_color = (.2, .2, .2) - } - } - - def Xform "MatBall1" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - over Material "Material" ( - prepend references= - variants = { - string preset = "White" - string resolution = "res1024x1024" - } - ) - { - # Adding custom parameters, note that these are - # stronger than parameters set through presets - float procedural_sbsar:tearing = 0.8 - float3 procedural_sbsar:cardboard_color = (.7, .3, .1) - } - double3 xformOp:translate = (110, 0, 0) - - } - -} - diff --git a/sbsar/data/baselines/symbolRenaming/trickyNames.usda b/sbsar/data/baselines/symbolRenaming/trickyNames.usda deleted file mode 100644 index 2a144497..00000000 --- a/sbsar/data/baselines/symbolRenaming/trickyNames.usda +++ /dev/null @@ -1,1802 +0,0 @@ -#usda 1.0 -( - defaultPrim = "Looks" -) - -def Scope "Looks" -{ - def Material "_trickyName" ( - variants = { - string preset = "__default__" - } - prepend variantSets = ["resolution", "preset"] - ) - { - asset inputs:ambientOcclusion_texture - asset inputs:baseColor_texture - float4 inputs:metallic_default = (0, 0, 0, 1) ( - customData = { - dictionary range = { - float4 max = (0, 0, 0, 1) - float4 min = (1, 1, 1, 1) - } - } - ) - asset inputs:metallic_texture - float inputs:metallic_textureInfluence = 1 ( - customData = { - dictionary range = { - float max = 1 - float min = 0 - } - } - ) - asset inputs:normal_texture - float4 inputs:roughness_default = (0.5, 0.5, 0.5, 1) ( - customData = { - dictionary range = { - float4 max = (0, 0, 0, 1) - float4 min = (1, 1, 1, 1) - } - } - ) - asset inputs:roughness_texture - float inputs:roughness_textureInfluence = 1 ( - customData = { - dictionary range = { - float max = 1 - float min = 0 - } - } - ) - float inputs:uvrotation = 0 - float2 inputs:uvscale = (1, 1) - float2 inputs:uvtranslation = (0, 0) - token outputs:mtlx:surface.connect = - token outputs:adobe:surface.connect = - token outputs:surface.connect = - - def "UsdPreviewSurface" - { - def Shader "ShaderUsdPreviewSurface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor.connect = - float inputs:metallic.connect = - normal3f inputs:normal.connect = - float inputs:occlusion.connect = - float inputs:roughness.connect = - float2 outputs:dispacement - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "st" - float2 outputs:result - } - - def Shader "uvTransform" - { - uniform token info:id = "UsdTransform2d" - float2 inputs:in.connect = - float inputs:rotation.connect = - float2 inputs:scale.connect = - float2 inputs:translation.connect = - float2 outputs:result - } - - def Shader "filediffuseColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "fileocclusion" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileroughness" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filemetallic" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filenormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - } - - def "ASM" - { - def Shader "AdobeStandardMaterial" - { - uniform token info:id = "AdobeStandardMaterial_4_0" - color3f inputs:baseColor.connect = - normal3f inputs:normal.connect = - float inputs:roughness.connect = - float inputs:metallic.connect = - float inputs:height.connect = - float inputs:opacity.connect = - float inputs:specularLevel.connect = - color3f inputs:specularEdgeColor.connect = - float inputs:ior.connect = - float inputs:anisotropyLevel.connect = - float inputs:anisotropyAngle.connect = - float inputs:sheenOpacity.connect = - color3f inputs:sheenColor.connect = - float inputs:sheenRoughness.connect = - float inputs:coatOpacity.connect = - color3f inputs:coatColor.connect = - normal3f inputs:coatNormal.connect = - float inputs:coatRoughness.connect = - float inputs:coatSpecularLevel.connect = - float inputs:translucency.connect = - color3f inputs:scatteringDistanceScale.connect = - float2 outputs:dispacement - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "st" - float2 outputs:result - } - - def Shader "uvTransform" - { - uniform token info:id = "UsdTransform2d" - float2 inputs:in.connect = - float inputs:rotation.connect = - float2 inputs:scale.connect = - float2 inputs:translation.connect = - float2 outputs:result - } - - def Shader "filebaseColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filenormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - - def Shader "fileroughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filemetallic" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileheight" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileopacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filespecularLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filespecularEdgeColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "fileior" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileanisotropyLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileanisotropyAngle" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filesheenOpacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filesheenColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filesheenRoughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatOpacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filecoatNormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - - def Shader "filecoatRoughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatSpecularLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filetranslucency" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filescatteringDistanceScale" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - } - - def "Mtlx" - { - def Shader "ND_adobe_standard_material" - { - uniform token info:id = "ND_adobe_standard_material" - float inputs:ambient_occlusion.connect = - color3f inputs:base_color.connect = - float inputs:metallic.connect = - float inputs:roughness.connect = - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "ND_texcoord_vector2" - float2 outputs:out - } - - def Shader "uvRotate" - { - uniform token info:id = "ND_rotate2d_vector2" - float inputs:amount.connect = - float2 inputs:in.connect = - float2 outputs:out - } - - def Shader "uvScale" - { - uniform token info:id = "ND_multiply_vector2" - float2 inputs:in1.connect = - float2 inputs:in2.connect = - float2 outputs:out - } - - def Shader "uvTranslate" - { - uniform token info:id = "ND_add_vector2" - float2 inputs:in1.connect = - float2 inputs:in2.connect = - float2 outputs:out - } - - def Shader "filebase_color" - { - uniform token info:id = "ND_image_color3" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - color3f outputs:out - } - - def Shader "fileambient_occlusion" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "fileroughness" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "filemetallic" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "filenormal" - { - uniform token info:id = "ND_image_vector3" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float3 outputs:out - } - } - variantSet "preset" = { - "__default__" ( - variants = { - string resolution = "res0512x0512" - } - prepend variantSets = "resolution" - ) { - variantSet "resolution" = { - "res0016x0016" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - - } - "res0032x0032" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - - } - "res0064x0064" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - - } - "res0128x0128" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - - } - "res0256x0256" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - - } - "res0512x0512" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - - } - "res1024x1024" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - - } - "res2048x2048" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - - } - "res4096x4096" { - asset inputs:ambientOcclusion_texture = @graphs/-trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:baseColor_texture = @graphs/-trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:metallic_texture = @graphs/-trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:normal_texture = @graphs/-trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:roughness_texture = @graphs/-trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - - } - } - - } - } - } - - def Material "_00startwithnumber" ( - variants = { - string preset = "__default__" - } - prepend variantSets = ["resolution", "preset"] - ) - { - asset inputs:ambientOcclusion_texture - asset inputs:baseColor_texture - float4 inputs:metallic_default = (0, 0, 0, 1) ( - customData = { - dictionary range = { - float4 max = (0, 0, 0, 1) - float4 min = (1, 1, 1, 1) - } - } - ) - asset inputs:metallic_texture - float inputs:metallic_textureInfluence = 1 ( - customData = { - dictionary range = { - float max = 1 - float min = 0 - } - } - ) - asset inputs:normal_texture - float4 inputs:roughness_default = (0.5, 0.5, 0.5, 1) ( - customData = { - dictionary range = { - float4 max = (0, 0, 0, 1) - float4 min = (1, 1, 1, 1) - } - } - ) - asset inputs:roughness_texture - float inputs:roughness_textureInfluence = 1 ( - customData = { - dictionary range = { - float max = 1 - float min = 0 - } - } - ) - float inputs:uvrotation = 0 - float2 inputs:uvscale = (1, 1) - float2 inputs:uvtranslation = (0, 0) - token outputs:mtlx:surface.connect = - token outputs:adobe:surface.connect = - token outputs:surface.connect = - - def "UsdPreviewSurface" - { - def Shader "ShaderUsdPreviewSurface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor.connect = - float inputs:metallic.connect = - normal3f inputs:normal.connect = - float inputs:occlusion.connect = - float inputs:roughness.connect = - float2 outputs:dispacement - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "st" - float2 outputs:result - } - - def Shader "uvTransform" - { - uniform token info:id = "UsdTransform2d" - float2 inputs:in.connect = - float inputs:rotation.connect = - float2 inputs:scale.connect = - float2 inputs:translation.connect = - float2 outputs:result - } - - def Shader "filediffuseColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "fileocclusion" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileroughness" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filemetallic" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filenormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - } - - def "ASM" - { - def Shader "AdobeStandardMaterial" - { - uniform token info:id = "AdobeStandardMaterial_4_0" - color3f inputs:baseColor.connect = - normal3f inputs:normal.connect = - float inputs:roughness.connect = - float inputs:metallic.connect = - float inputs:height.connect = - float inputs:opacity.connect = - float inputs:specularLevel.connect = - color3f inputs:specularEdgeColor.connect = - float inputs:ior.connect = - float inputs:anisotropyLevel.connect = - float inputs:anisotropyAngle.connect = - float inputs:sheenOpacity.connect = - color3f inputs:sheenColor.connect = - float inputs:sheenRoughness.connect = - float inputs:coatOpacity.connect = - color3f inputs:coatColor.connect = - normal3f inputs:coatNormal.connect = - float inputs:coatRoughness.connect = - float inputs:coatSpecularLevel.connect = - float inputs:translucency.connect = - color3f inputs:scatteringDistanceScale.connect = - float2 outputs:dispacement - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "st" - float2 outputs:result - } - - def Shader "uvTransform" - { - uniform token info:id = "UsdTransform2d" - float2 inputs:in.connect = - float inputs:rotation.connect = - float2 inputs:scale.connect = - float2 inputs:translation.connect = - float2 outputs:result - } - - def Shader "filebaseColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filenormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - - def Shader "fileroughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filemetallic" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileheight" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileopacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filespecularLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filespecularEdgeColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "fileior" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileanisotropyLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileanisotropyAngle" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filesheenOpacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filesheenColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filesheenRoughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatOpacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filecoatNormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - - def Shader "filecoatRoughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatSpecularLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filetranslucency" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filescatteringDistanceScale" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - } - - def "Mtlx" - { - def Shader "ND_adobe_standard_material" - { - uniform token info:id = "ND_adobe_standard_material" - float inputs:ambient_occlusion.connect = - color3f inputs:base_color.connect = - float inputs:metallic.connect = - float inputs:roughness.connect = - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "ND_texcoord_vector2" - float2 outputs:out - } - - def Shader "uvRotate" - { - uniform token info:id = "ND_rotate2d_vector2" - float inputs:amount.connect = - float2 inputs:in.connect = - float2 outputs:out - } - - def Shader "uvScale" - { - uniform token info:id = "ND_multiply_vector2" - float2 inputs:in1.connect = - float2 inputs:in2.connect = - float2 outputs:out - } - - def Shader "uvTranslate" - { - uniform token info:id = "ND_add_vector2" - float2 inputs:in1.connect = - float2 inputs:in2.connect = - float2 outputs:out - } - - def Shader "filebase_color" - { - uniform token info:id = "ND_image_color3" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - color3f outputs:out - } - - def Shader "fileambient_occlusion" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "fileroughness" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "filemetallic" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "filenormal" - { - uniform token info:id = "ND_image_vector3" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float3 outputs:out - } - } - variantSet "preset" = { - "__default__" ( - variants = { - string resolution = "res0512x0512" - } - prepend variantSets = "resolution" - ) { - variantSet "resolution" = { - "res0016x0016" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - - } - "res0032x0032" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - - } - "res0064x0064" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - - } - "res0128x0128" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - - } - "res0256x0256" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - - } - "res0512x0512" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - - } - "res1024x1024" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - - } - "res2048x2048" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - - } - "res4096x4096" { - asset inputs:ambientOcclusion_texture = @graphs/00startwithnumber/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:baseColor_texture = @graphs/00startwithnumber/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:metallic_texture = @graphs/00startwithnumber/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:normal_texture = @graphs/00startwithnumber/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:roughness_texture = @graphs/00startwithnumber/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - - } - } - - } - } - } - - def Material "_trickyName_" ( - variants = { - string preset = "__default__" - } - prepend variantSets = ["resolution", "preset"] - ) - { - asset inputs:ambientOcclusion_texture - asset inputs:baseColor_texture - float4 inputs:metallic_default = (0, 0, 0, 1) ( - customData = { - dictionary range = { - float4 max = (0, 0, 0, 1) - float4 min = (1, 1, 1, 1) - } - } - ) - asset inputs:metallic_texture - float inputs:metallic_textureInfluence = 1 ( - customData = { - dictionary range = { - float max = 1 - float min = 0 - } - } - ) - asset inputs:normal_texture - float4 inputs:roughness_default = (0.5, 0.5, 0.5, 1) ( - customData = { - dictionary range = { - float4 max = (0, 0, 0, 1) - float4 min = (1, 1, 1, 1) - } - } - ) - asset inputs:roughness_texture - float inputs:roughness_textureInfluence = 1 ( - customData = { - dictionary range = { - float max = 1 - float min = 0 - } - } - ) - float inputs:uvrotation = 0 - float2 inputs:uvscale = (1, 1) - float2 inputs:uvtranslation = (0, 0) - token outputs:mtlx:surface.connect = - token outputs:adobe:surface.connect = - token outputs:surface.connect = - - def "UsdPreviewSurface" - { - def Shader "ShaderUsdPreviewSurface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor.connect = - float inputs:metallic.connect = - normal3f inputs:normal.connect = - float inputs:occlusion.connect = - float inputs:roughness.connect = - float2 outputs:dispacement - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "st" - float2 outputs:result - } - - def Shader "uvTransform" - { - uniform token info:id = "UsdTransform2d" - float2 inputs:in.connect = - float inputs:rotation.connect = - float2 inputs:scale.connect = - float2 inputs:translation.connect = - float2 outputs:result - } - - def Shader "filediffuseColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "fileocclusion" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileroughness" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filemetallic" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filenormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - } - - def "ASM" - { - def Shader "AdobeStandardMaterial" - { - uniform token info:id = "AdobeStandardMaterial_4_0" - color3f inputs:baseColor.connect = - normal3f inputs:normal.connect = - float inputs:roughness.connect = - float inputs:metallic.connect = - float inputs:height.connect = - float inputs:opacity.connect = - float inputs:specularLevel.connect = - color3f inputs:specularEdgeColor.connect = - float inputs:ior.connect = - float inputs:anisotropyLevel.connect = - float inputs:anisotropyAngle.connect = - float inputs:sheenOpacity.connect = - color3f inputs:sheenColor.connect = - float inputs:sheenRoughness.connect = - float inputs:coatOpacity.connect = - color3f inputs:coatColor.connect = - normal3f inputs:coatNormal.connect = - float inputs:coatRoughness.connect = - float inputs:coatSpecularLevel.connect = - float inputs:translucency.connect = - color3f inputs:scatteringDistanceScale.connect = - float2 outputs:dispacement - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "UsdPrimvarReader_float2" - token inputs:varname = "st" - float2 outputs:result - } - - def Shader "uvTransform" - { - uniform token info:id = "UsdTransform2d" - float2 inputs:in.connect = - float inputs:rotation.connect = - float2 inputs:scale.connect = - float2 inputs:translation.connect = - float2 outputs:result - } - - def Shader "filebaseColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filenormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - - def Shader "fileroughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filemetallic" - { - uniform token info:id = "UsdUVTexture" - float4 inputs:fallback.connect = - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileheight" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileopacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filespecularLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filespecularEdgeColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "fileior" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileanisotropyLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "fileanisotropyAngle" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filesheenOpacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filesheenColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filesheenRoughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatOpacity" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatColor" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "sRGB" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - color3f outputs:rgb - } - - def Shader "filecoatNormal" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - - def Shader "filecoatRoughness" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filecoatSpecularLevel" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filetranslucency" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - float outputs:r - } - - def Shader "filescatteringDistanceScale" - { - uniform token info:id = "UsdUVTexture" - asset inputs:file.connect = - token inputs:sourceColorSpace = "raw" - float2 inputs:st.connect = - token inputs:wrapS = "repeat" - token inputs:wrapT = "repeat" - normal3f outputs:rgb - } - } - - def "Mtlx" - { - def Shader "ND_adobe_standard_material" - { - uniform token info:id = "ND_adobe_standard_material" - float inputs:ambient_occlusion.connect = - color3f inputs:base_color.connect = - float inputs:metallic.connect = - float inputs:roughness.connect = - float2 outputs:surface - } - - def Shader "texCoordReader" - { - uniform token info:id = "ND_texcoord_vector2" - float2 outputs:out - } - - def Shader "uvRotate" - { - uniform token info:id = "ND_rotate2d_vector2" - float inputs:amount.connect = - float2 inputs:in.connect = - float2 outputs:out - } - - def Shader "uvScale" - { - uniform token info:id = "ND_multiply_vector2" - float2 inputs:in1.connect = - float2 inputs:in2.connect = - float2 outputs:out - } - - def Shader "uvTranslate" - { - uniform token info:id = "ND_add_vector2" - float2 inputs:in1.connect = - float2 inputs:in2.connect = - float2 outputs:out - } - - def Shader "filebase_color" - { - uniform token info:id = "ND_image_color3" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - color3f outputs:out - } - - def Shader "fileambient_occlusion" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "fileroughness" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "filemetallic" - { - uniform token info:id = "ND_image_float" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float outputs:out - } - - def Shader "filenormal" - { - uniform token info:id = "ND_image_vector3" - asset inputs:file.connect = - float2 inputs:texcoord.connect = - string inputs:uaddressmode = "periodic" - string inputs:vaddressmode = "periodic" - float3 outputs:out - } - } - variantSet "preset" = { - "__default__" ( - variants = { - string resolution = "res0512x0512" - } - prepend variantSets = "resolution" - ) { - variantSet "resolution" = { - "res0016x0016" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[4,4]}@ - - } - "res0032x0032" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[5,5]}@ - - } - "res0064x0064" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[6,6]}@ - - } - "res0128x0128" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[7,7]}@ - - } - "res0256x0256" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[8,8]}@ - - } - "res0512x0512" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[9,9]}@ - - } - "res1024x1024" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[10,10]}@ - - } - "res2048x2048" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[11,11]}@ - - } - "res4096x4096" { - asset inputs:ambientOcclusion_texture = @graphs/_trickyName/images?usage=ambientOcclusion#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:baseColor_texture = @graphs/_trickyName/images?usage=baseColor#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:metallic_texture = @graphs/_trickyName/images?usage=metallic#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:normal_texture = @graphs/_trickyName/images?usage=normal#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - asset inputs:roughness_texture = @graphs/_trickyName/images?usage=roughness#packageHash=1199ecfb2b4026f9#params={"$outputsize":[12,12]}@ - - } - } - - } - } - } -} - diff --git a/sbsar/data/direct_reference.usda b/sbsar/data/direct_reference.usda deleted file mode 100644 index 8354ed23..00000000 --- a/sbsar/data/direct_reference.usda +++ /dev/null @@ -1,27 +0,0 @@ -#usda 1.0 -( - upAxis = "Y" -) -def Xform "World" -{ - def Xform "MatBall" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"{ - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - } - def Material "Material" ( - prepend references=@sbsar/CardBoard.sbsar@ - # Override presets and resolution - variants = { - string preset = "Torn" - string resolution = "res1024x1024" - } - ) - {} -} - diff --git a/sbsar/data/environment.usda b/sbsar/data/environment.usda deleted file mode 100644 index c6efd402..00000000 --- a/sbsar/data/environment.usda +++ /dev/null @@ -1,8 +0,0 @@ -#usda 1.0 -( - subLayers = [@./sbsar/env_map_blue.sbsar@] -) - -def Cube "box" { - double size = 4.0 -} diff --git a/sbsar/data/mat-ball.usd b/sbsar/data/mat-ball.usd deleted file mode 100644 index aee78210..00000000 Binary files a/sbsar/data/mat-ball.usd and /dev/null differ diff --git a/sbsar/data/random_seed.usda b/sbsar/data/random_seed.usda deleted file mode 100644 index 9da26094..00000000 --- a/sbsar/data/random_seed.usda +++ /dev/null @@ -1,37 +0,0 @@ -#usda 1.0 -( - upAxis = "Y" - subLayers = [@./sbsar/CardBoard.sbsar@] -) -def Xform "World" -{ - def Xform "MatBall" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - over Material "Material" ( - prepend references= - variants = { - string preset = "White" - string resolution = "res1024x1024" - } - ) - { - # Adding custom parameters, note that these are - # stronger than parameters set through presets - float procedural_sbsar:tearing = 0.8 - float3 procedural_sbsar:cardboard_color = (.7, .3, .1) - int procedural_sbsar:_randomseed = 1 - } - } - -} - diff --git a/sbsar/data/sbsar/CardBoard.sbs b/sbsar/data/sbsar/CardBoard.sbs deleted file mode 100644 index a91a6742..00000000 --- a/sbsar/data/sbsar/CardBoard.sbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sbsar/data/sbsar/CardBoard.sbsar b/sbsar/data/sbsar/CardBoard.sbsar deleted file mode 100644 index effd1e36..00000000 Binary files a/sbsar/data/sbsar/CardBoard.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/DoubleCardBoard.sbs b/sbsar/data/sbsar/DoubleCardBoard.sbs deleted file mode 100644 index e1da9de5..00000000 --- a/sbsar/data/sbsar/DoubleCardBoard.sbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sbsar/data/sbsar/DoubleCardBoard.sbsar b/sbsar/data/sbsar/DoubleCardBoard.sbsar deleted file mode 100644 index c97866d9..00000000 Binary files a/sbsar/data/sbsar/DoubleCardBoard.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/Fabric_ASM.sbsar b/sbsar/data/sbsar/Fabric_ASM.sbsar deleted file mode 100644 index 110beca0..00000000 Binary files a/sbsar/data/sbsar/Fabric_ASM.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/Tiles.sbs b/sbsar/data/sbsar/Tiles.sbs deleted file mode 100644 index a0196fdd..00000000 --- a/sbsar/data/sbsar/Tiles.sbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sbsar/data/sbsar/Tiles.sbsar b/sbsar/data/sbsar/Tiles.sbsar deleted file mode 100644 index 2f99be98..00000000 Binary files a/sbsar/data/sbsar/Tiles.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/asm_material.sbsar b/sbsar/data/sbsar/asm_material.sbsar deleted file mode 100644 index bf8d898c..00000000 Binary files a/sbsar/data/sbsar/asm_material.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/env_map_blue.sbsar b/sbsar/data/sbsar/env_map_blue.sbsar deleted file mode 100644 index 388cd174..00000000 Binary files a/sbsar/data/sbsar/env_map_blue.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/input_image.resources/Gradient.png b/sbsar/data/sbsar/input_image.resources/Gradient.png deleted file mode 100644 index 66447e72..00000000 Binary files a/sbsar/data/sbsar/input_image.resources/Gradient.png and /dev/null differ diff --git a/sbsar/data/sbsar/input_image.resources/Spike.png b/sbsar/data/sbsar/input_image.resources/Spike.png deleted file mode 100644 index edd2a539..00000000 Binary files a/sbsar/data/sbsar/input_image.resources/Spike.png and /dev/null differ diff --git a/sbsar/data/sbsar/input_image.sbs b/sbsar/data/sbsar/input_image.sbs deleted file mode 100644 index 4acd7424..00000000 --- a/sbsar/data/sbsar/input_image.sbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sbsar/data/sbsar/input_image.sbsar b/sbsar/data/sbsar/input_image.sbsar deleted file mode 100644 index 76f6c41e..00000000 Binary files a/sbsar/data/sbsar/input_image.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/natural_lambskin_leather.sbsar b/sbsar/data/sbsar/natural_lambskin_leather.sbsar deleted file mode 100644 index d4fa4f95..00000000 Binary files a/sbsar/data/sbsar/natural_lambskin_leather.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/outputvalues_asm.sbsar b/sbsar/data/sbsar/outputvalues_asm.sbsar deleted file mode 100644 index 13abd63e..00000000 Binary files a/sbsar/data/sbsar/outputvalues_asm.sbsar and /dev/null differ diff --git a/sbsar/data/sbsar/symbolRenaming/trickyNames.sbs b/sbsar/data/sbsar/symbolRenaming/trickyNames.sbs deleted file mode 100644 index b67539df..00000000 --- a/sbsar/data/sbsar/symbolRenaming/trickyNames.sbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sbsar/data/sbsar/symbolRenaming/trickyNames.sbsar b/sbsar/data/sbsar/symbolRenaming/trickyNames.sbsar deleted file mode 100644 index 486df4b5..00000000 Binary files a/sbsar/data/sbsar/symbolRenaming/trickyNames.sbsar and /dev/null differ diff --git a/sbsar/data/several_sbsar.usda b/sbsar/data/several_sbsar.usda deleted file mode 100644 index 2cc49e47..00000000 --- a/sbsar/data/several_sbsar.usda +++ /dev/null @@ -1,38 +0,0 @@ -#usda 1.0 -( - upAxis = "Y" - subLayers = [@./sbsar/CardBoard.sbsar@, @./sbsar/Tiles.sbsar@, @./sbsar/env_map_blue.sbsar@] -) -def Xform "World" -{ - def Xform "MatBall1" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - double3 xformOp:translate = (-110, 0, 0) - } - def Xform "MatBall2" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - double3 xformOp:translate = (0, 0, 0) - } - -} - diff --git a/sbsar/data/simplest.usda b/sbsar/data/simplest.usda deleted file mode 100644 index cb591a20..00000000 --- a/sbsar/data/simplest.usda +++ /dev/null @@ -1,23 +0,0 @@ -#usda 1.0 -( - upAxis = "Y" - subLayers = [@./sbsar/CardBoard.sbsar@] -) -def Xform "World" -{ - def Xform "MatBall" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - } - -} - diff --git a/sbsar/data/variants.usda b/sbsar/data/variants.usda deleted file mode 100644 index 0a45fdcd..00000000 --- a/sbsar/data/variants.usda +++ /dev/null @@ -1,83 +0,0 @@ -#usda 1.0 -( - defaultPrim = "World" - metersPerUnit = 0.009999999776482582 - timeCodesPerSecond = 24 - upAxis = "Y" - subLayers = [@./sbsar/CardBoard.sbsar@] -) -def Xform "World" -{ - def Xform "MatBall0" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - double3 xformOp:translate = (-110, 0, 0) - over Material "Material" ( - prepend references= - # Override presets and resolution - variants = { - string preset = "Torn" - string resolution = "res1024x1024" - } - ) - {} - } - def Xform "MatBall1" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - double3 xformOp:translate = (0, 0, 0) - over Material "Material" ( - prepend references= - variants = { - string preset = "White" - string resolution = "res1024x1024" - } - ) - {} - } - def Xform "MatBall2" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - over Material "Material" ( - prepend references= - variants = { - string preset = "White" - string resolution = "res1024x1024" - } - ) - { - # Adding custom parameters, note that these are - # stronger than parameters set through presets - float procedural_sbsar:tearing = 0.8 - float3 procedural_sbsar:cardboard_color = (.7, .3, .1) - } - double3 xformOp:translate = (110, 0, 0) - } -} diff --git a/sbsar/data/variants_image.usda b/sbsar/data/variants_image.usda deleted file mode 100644 index 8177ecb6..00000000 --- a/sbsar/data/variants_image.usda +++ /dev/null @@ -1,80 +0,0 @@ -#usda 1.0 -( - defaultPrim = "World" - metersPerUnit = 0.009999999776482582 - timeCodesPerSecond = 24 - upAxis = "Y" - subLayers = [@./sbsar/input_image.sbsar@] -) -def Xform "World" -{ - def Xform "MatBall0" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - double3 xformOp:translate = (-110, 0, 0) - over Material "Material" ( - prepend references= - # Override presets and resolution - variants = { - string resolution = "res1024x1024" - } - ) - {} - } - def Xform "MatBall1" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - double3 xformOp:translate = (0, 0, 0) - over Material "Material" ( - prepend references= - variants = { - string resolution = "res1024x1024" - } - ) - {} - } - def Xform "MatBall2" ( - prepend references=@./mat-ball.usd@ - ) - { - over Mesh "Mat_Ball"( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - rel material:binding = ( - bindMaterialAs = "weakerThanDescendants" - ) - } - over Material "Material" ( - prepend references= - variants = { - string resolution = "res1024x1024" - } - ) - { - # Adding custom parameters, note that these are - # stronger than parameters set through presets - asset procedural_sbsar:foreground = @sbsar/input_image.resources/Gradient.png@ - asset procedural_sbsar:background = @sbsar/input_image.resources/Spike.png@ - } - double3 xformOp:translate = (110, 0, 0) - } -} diff --git a/sbsar/src/CMakeLists.txt b/sbsar/src/CMakeLists.txt index 5a3a32d7..60409d75 100644 --- a/sbsar/src/CMakeLists.txt +++ b/sbsar/src/CMakeLists.txt @@ -1,6 +1,8 @@ set(PLUGIN_NAME usdSbsar) add_library(${PLUGIN_NAME} SHARED ${SRC}) +set(SBSAR_EXT_LIST "sbsar;Sbsar;SBSAR" PARENT_SCOPE) + set(PUBLIC_HEADERS api.h) target_sources(${PLUGIN_NAME} @@ -38,7 +40,7 @@ target_sources(${PLUGIN_NAME} ${PUBLIC_HEADERS}) -target_include_directories(${PLUGIN_NAME} PUBLIC .) +target_include_directories(${PLUGIN_NAME} PUBLIC $) # target properties if (CMAKE_CXX_STANDARD) @@ -55,8 +57,11 @@ else() set(_pxr_include_dir "${PXR_INCLUDE_DIRS}") endif () -target_include_directories( - ${PLUGIN_NAME} PRIVATE ${_pxr_include_dir} ${_boost_include_dir}) +target_include_directories(${PLUGIN_NAME} + PRIVATE + $ + $ +) target_link_libraries( ${PLUGIN_NAME} @@ -147,39 +152,51 @@ endif() # WIN32 # Allow an option for deferring the path replacement to install time if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) - set(PLUG_INFO_LIBRARY_PATH "\$\{PLUG_INFO_LIBRARY_PATH\}") + # We still need to go through `configure_file` even with `USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT` because we burn additional CMake variable beyond PLUG_INFO_LIBRARY_PATH + # So we set `PLUG_INFO_LIBRARY_PATH` as a no op value and let other CMake variables being burnt in + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") else() - set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}${PLUGIN_NAME}${CMAKE_SHARED_LIBRARY_SUFFIX}") + set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}${PLUGIN_NAME}${CMAKE_SHARED_LIBRARY_SUFFIX}") endif() + configure_file(plugInfo.json.in plugInfo.json) +set_property(TARGET ${PLUGIN_NAME} APPEND PROPERTY RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json") +set_property(TARGET ${PLUGIN_NAME} APPEND PROPERTY RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") set_target_properties(${PLUGIN_NAME} PROPERTIES PUBLIC_HEADER ${PUBLIC_HEADERS}) target_compile_definitions(${PLUGIN_NAME} PRIVATE USDSBSAR_EXPORTS) -set_target_properties(${PLUGIN_NAME} PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) -set(_resource_list ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json generatedSchema.usda schema.usda) -set_target_properties(${PLUGIN_NAME} PROPERTIES RESOURCE_FILES "${_resource_list}") +set_property(TARGET ${PLUGIN_NAME} APPEND PROPERTY RESOURCE_FILES "generatedSchema.usda") +set_property(TARGET ${PLUGIN_NAME} APPEND PROPERTY RESOURCE_FILES "schema.usda") # USDSBSAR_DESTINATION is set in the parent scope by the add_usd_fileformat macro if(USDSBSAR_ENABLE_INSTALL) + set_property(TARGET ${PLUGIN_NAME} + APPEND PROPERTY + INSTALL_RPATH "${plugin_install_rpath_root}/." + ) # Install the plugInfo.json file for the specific plugin install( TARGETS ${PLUGIN_NAME} + EXPORT usd-fileformats-targets RUNTIME DESTINATION ${USDSBSAR_DESTINATION} COMPONENT Runtime LIBRARY DESTINATION ${USDSBSAR_DESTINATION} COMPONENT Runtime + ARCHIVE DESTINATION ${USDSBSAR_DESTINATION} COMPONENT Runtime RESOURCE DESTINATION ${USDSBSAR_DESTINATION}/${PLUGIN_NAME}/resources COMPONENT Runtime PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} COMPONENT Devel) - # Install the master plugInfo.json file for the install directory Note that this - # one exists to make sure the plugin is easy to deploy in the plugin directory - # of a pre-existing usd build which assumes the plugin has a - # /resources/plugInfo.json - install( - FILES ${CMAKE_CURRENT_SOURCE_DIR}/plugInfo.root.json - DESTINATION ${USDSBSAR_DESTINATION} - RENAME plugInfo.json - COMPONENT Runtime) + if(USD_FILEFORMATS_ENABLE_INSTALL_PLUGINFO_ROOT) + # Install the master plugInfo.json file for the install directory Note that this + # one exists to make sure the plugin is easy to deploy in the plugin directory + # of a pre-existing usd build which assumes the plugin has a + # /resources/plugInfo.json + install( + FILES ${CMAKE_CURRENT_SOURCE_DIR}/plugInfo.root.json + DESTINATION ${USDSBSAR_DESTINATION} + RENAME plugInfo.json + COMPONENT Runtime) + endif() endif() # ############################################################################## diff --git a/sbsar/src/assetResolver/sbsarImage.cpp b/sbsar/src/assetResolver/sbsarImage.cpp index 0ae72012..070d271e 100644 --- a/sbsar/src/assetResolver/sbsarImage.cpp +++ b/sbsar/src/assetResolver/sbsarImage.cpp @@ -326,6 +326,15 @@ SbsarImage::_OpenForReading(const std::string& filename, // Render the textures mRenderResultImage = adobe::usd::sbsar::renderSbsarAsset(mSbsarAsset->GetPackagePath(), mSbsarAsset->GetPackagedPathNoExt()); + if (!mRenderResultImage) { + // No error logged here intentionally. File-missing failures are already caught and + // reported above (OpenAsset / dynamic_pointer_cast). If we reach this point the sbsar + // package exists but the render returned no result — this is the expected outcome when + // an sbsar image input has no image connected (empty/undefined). Genuine render failures + // (engine errors, shutdown, timeout) are already logged inside renderSbsarAsset / + // requestRender, so a second log here would be redundant noise for both cases. + return false; + } unsigned char pixelFormat = _GetPixelFormat(); const bool isSRGB = [&]() -> bool { diff --git a/sbsar/src/plugInfo.json.in b/sbsar/src/plugInfo.json.in index 2a3de84a..4d53f88d 100644 --- a/sbsar/src/plugInfo.json.in +++ b/sbsar/src/plugInfo.json.in @@ -82,13 +82,13 @@ "precedence": 1 }, "SbsarConfig": { - "assetCacheSize": ${USDSBSAR_CACHE_SIZE}, - "inputImageCacheSize": ${USDSBSAR_IMAGE_CACHE_SIZE}, - "packageCacheSize": ${USDSBSAR_PACKAGE_LIMIT} + "assetCacheSize": @USDSBSAR_CACHE_SIZE@, + "inputImageCacheSize": @USDSBSAR_IMAGE_CACHE_SIZE@, + "packageCacheSize": @USDSBSAR_PACKAGE_LIMIT@ } } }, - "LibraryPath": "${PLUG_INFO_LIBRARY_PATH}", + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", "Name": "usdSbsar", "ResourcePath": "resources", "Root": "..", diff --git a/sbsar/src/sbsarEngine/sbsarAssetCache.cpp b/sbsar/src/sbsarEngine/sbsarAssetCache.cpp index b3b008b3..5f34ec1a 100644 --- a/sbsar/src/sbsarEngine/sbsarAssetCache.cpp +++ b/sbsar/src/sbsarEngine/sbsarAssetCache.cpp @@ -91,6 +91,9 @@ RenderResultCache::computeSize() { m_size = 0; for (const auto& asset : m_assets) { + if (!asset.second) { + continue; + } m_size += _computePixelBufferSize(asset.second->getTexture()); } } diff --git a/sbsar/src/sbsarEngine/sbsarAssetCache.h b/sbsar/src/sbsarEngine/sbsarAssetCache.h index e663a813..79e3de85 100644 --- a/sbsar/src/sbsarEngine/sbsarAssetCache.h +++ b/sbsar/src/sbsarEngine/sbsarAssetCache.h @@ -50,9 +50,11 @@ class USDSBSAR_API RenderResultCache //! Key : usage of the value std::unordered_map m_numericalValues; //! Time of creation of the assets or the last time it was used. - std::chrono::time_point m_lastAccessTime; + std::chrono::time_point m_lastAccessTime{ + std::chrono::steady_clock::now() + }; //! Total size of all asset in the map in bytes. - std::size_t m_size; + std::size_t m_size = 0; }; //! \brief Cache to store all assets render by the substance engine. diff --git a/sbsar/src/sbsarEngine/sbsarInputImageCache.cpp b/sbsar/src/sbsarEngine/sbsarInputImageCache.cpp index cc9f32a3..4f121527 100644 --- a/sbsar/src/sbsarEngine/sbsarInputImageCache.cpp +++ b/sbsar/src/sbsarEngine/sbsarInputImageCache.cpp @@ -31,6 +31,8 @@ struct InputImageCacheData { //! Input image. InputImage::SPtr image; + //! Resolved file path stored so SAL callers can re-load the image with a GPU-compatible API. + std::string path; //! Creation time of the image, or last access time. std::chrono::time_point lastAccessTime; //! Image size in bytes. @@ -219,6 +221,7 @@ _loadAndAddInputImageData(InputImageCache& inputImageCache, const std::string& r if (inputImage == nullptr) return 0; data.image = inputImage; + data.path = resolvedAssetPath; data.size = size; inputImageCache.cache[hash] = data; inputImageCache.size += size; @@ -274,6 +277,17 @@ getImageFromInputImageCache(std::size_t hash) return _getInputImageCacheData(globalInputImageCache.inputImageCache, hash); } +std::string +getPathFromInputImageCache(std::size_t hash) +{ + GlobalInputImageCache& globalInputImageCache = _getGlobalInputImageCache(); + std::lock_guard guard(globalInputImageCache.mutex); + auto it = globalInputImageCache.inputImageCache.cache.find(hash); + if (it == globalInputImageCache.inputImageCache.cache.end()) + return {}; + return it->second.path; +} + void clearInputImageCache() { diff --git a/sbsar/src/sbsarEngine/sbsarInputImageCache.h b/sbsar/src/sbsarEngine/sbsarInputImageCache.h index c9bf534b..26f94bea 100644 --- a/sbsar/src/sbsarEngine/sbsarInputImageCache.h +++ b/sbsar/src/sbsarEngine/sbsarInputImageCache.h @@ -33,6 +33,14 @@ addImageToInputImageCache(const std::string& resolvedAssetPath); USDSBSAR_API SubstanceAir::InputImage::SPtr getImageFromInputImageCache(std::size_t hash); +//! \brief Get the resolved asset path for a cached image. +//! Allows callers compiled with a different Substance platform (e.g. SAL) to retrieve +//! the original file path and re-load the image in their own platform context. +//! \param hash The hash returned by addImageToInputImageCache(). +//! \return The resolved asset path, or an empty string if the hash is not in the cache. +USDSBSAR_API std::string +getPathFromInputImageCache(std::size_t hash); + //! \brief Erase all the cache. void clearInputImageCache(); diff --git a/sbsar/src/sbsarEngine/sbsarPackageCache.cpp b/sbsar/src/sbsarEngine/sbsarPackageCache.cpp index 12e84e29..e897c37a 100644 --- a/sbsar/src/sbsarEngine/sbsarPackageCache.cpp +++ b/sbsar/src/sbsarEngine/sbsarPackageCache.cpp @@ -9,6 +9,7 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +#include #include #include #include @@ -59,6 +60,10 @@ std::shared_ptr createInstance(const std::shared_ptr& package, const adobe::usd::sbsar::ParsePathResult& sbsarParameters) { + if (!package) { + TF_RUNTIME_ERROR("PackageCache: Package is null"); + return nullptr; + } const SubstanceAir::PackageDesc::Graphs& graphs = package->getGraphs(); // Find the graph with the right label const SubstanceAir::GraphDesc* selectedGraph = @@ -103,10 +108,11 @@ _readSbsar(const std::string& resolvedPackagePath, size_t* outContentHash) std::make_shared(buffer.get(), asset->GetSize()); w.Stop(); + const int64_t readMs = static_cast(w.GetMilliseconds()); TF_DEBUG_MSG(SBSAR_RENDER, - "PackageCache: Reading %s took: %lld ms\n", + "PackageCache: Reading %s took: %" PRId64 " ms\n", resolvedPackagePath.c_str(), - w.GetMilliseconds()); + readMs); if (!packageDesc->isValid()) { TF_RUNTIME_ERROR("PackageCache: SBSAR asset %s is not a valid package", @@ -193,16 +199,23 @@ _loadPackage(PackageCache& packageCache, } if (packageCache.size() > getSbsarConfig()->getPackageCacheSize()) { - // Remove the oldest entry - auto oldest = std::min_element( - packageCache.begin(), packageCache.end(), [](const auto& a, const auto& b) { - return a.second.lastAccessTime < b.second.lastAccessTime; - }); - TF_DEBUG(SBSAR_RENDER) - .Msg("PackageCache: removing oldest entry %s\n", oldest->first.c_str()); - getCacheStats().packageDeleted++; - getCacheStats().graphInstanceDeleted += oldest->second.instanceCache.size(); - packageCache.erase(oldest); + // Remove the oldest entry that isn't `it` (our just-inserted/touched entry). + // Evicting `it` would invalidate the iterator and cause UB on the return below. + auto oldest = packageCache.end(); + for (auto candidate = packageCache.begin(); candidate != packageCache.end(); ++candidate) { + if (candidate == it) + continue; + if (oldest == packageCache.end() || + candidate->second.lastAccessTime < oldest->second.lastAccessTime) + oldest = candidate; + } + if (oldest != packageCache.end()) { + TF_DEBUG(SBSAR_RENDER) + .Msg("PackageCache: removing oldest entry %s\n", oldest->first.c_str()); + getCacheStats().packageDeleted++; + getCacheStats().graphInstanceDeleted += oldest->second.instanceCache.size(); + packageCache.erase(oldest); + } } if (outContentHash != nullptr) { @@ -303,6 +316,12 @@ getGraphInstanceFromPackageCache(const std::string& resolvedPackagePath, // create instance and add it to the cache. std::shared_ptr newInstance = createInstance(entry.package, sbsarParameters); + if (!newInstance) { + TF_WARN("PackageCache: Failed to create graph instance for %s with parameters %s\n", + resolvedPackagePath.c_str(), + sbsarParameters.inputParameters.c_str()); + return nullptr; + } instanceCache[hash] = newInstance; return newInstance; } diff --git a/sbsar/src/sbsarEngine/sbsarRender.cpp b/sbsar/src/sbsarEngine/sbsarRender.cpp index 42629624..4f68b8e6 100644 --- a/sbsar/src/sbsarEngine/sbsarRender.cpp +++ b/sbsar/src/sbsarEngine/sbsarRender.cpp @@ -61,7 +61,7 @@ applyParameterValue(InputInstanceBase* i, SubstanceIOType type, const JsValue& v getAsDoubleArray(v, a); if (a.size() != 2) { TF_RUNTIME_ERROR("SbsarRender: cast 'Substance_IOType_Float2', incorrect data " - "size, the size is {}", + "size, the size is %zu", a.size()); return false; } @@ -78,7 +78,7 @@ applyParameterValue(InputInstanceBase* i, SubstanceIOType type, const JsValue& v getAsDoubleArray(v, a); if (a.size() != 3) { TF_RUNTIME_ERROR("SbsarRender: cast 'Substance_IOType_Float3', incorrect data " - "size, the size is {}", + "size, the size is %zu", a.size()); return false; } @@ -96,7 +96,7 @@ applyParameterValue(InputInstanceBase* i, SubstanceIOType type, const JsValue& v getAsDoubleArray(v, a); if (a.size() != 4) { TF_RUNTIME_ERROR("SbsarRender: cast 'Substance_IOType_Float4', incorrect data " - "size, the size is {}", + "size, the size is %zu", a.size()); return false; } @@ -130,7 +130,7 @@ applyParameterValue(InputInstanceBase* i, SubstanceIOType type, const JsValue& v getAsIntArray(v, a); if (a.size() != 2) { TF_RUNTIME_ERROR("SbsarRender: cast 'Substance_IOType_Integer2', incorrect data " - "size, the size is {}", + "size, the size is %zu", a.size()); return false; } @@ -147,7 +147,7 @@ applyParameterValue(InputInstanceBase* i, SubstanceIOType type, const JsValue& v getAsIntArray(v, a); if (a.size() != 3) { TF_RUNTIME_ERROR("SbsarRender: cast 'Substance_IOType_Integer3', incorrect data " - "size, the size is {}", + "size, the size is %zu", a.size()); return false; } @@ -164,7 +164,7 @@ applyParameterValue(InputInstanceBase* i, SubstanceIOType type, const JsValue& v getAsIntArray(v, a); if (a.size() != 4) { TF_RUNTIME_ERROR("SbsarRender: cast 'Substance_IOType_Integer4', incorrect data " - "size, the size is {}", + "size, the size is %zu", a.size()); return false; } @@ -178,7 +178,7 @@ applyParameterValue(InputInstanceBase* i, SubstanceIOType type, const JsValue& v return false; } const std::string& r = v.GetString(); - s->setString(r.c_str()); + s->setString(SubstanceAir::to_string(r)); break; } case Substance_IOType_Image: { @@ -313,10 +313,9 @@ convertToVtValue(const RenderResultNumericalBase& res) } void -renderGraph(Renderer& renderer, - GraphInstanceData& instanceData, - const ParsePathResult& sbsarParameters, - AssetCache& assetCache) +prepareGraph(Renderer& renderer, + GraphInstanceData& instanceData, + const ParsePathResult& sbsarParameters) { SubstanceAir::GraphInstance& instance = instanceData.getGraphInstance(); @@ -327,12 +326,24 @@ renderGraph(Renderer& renderer, } applyPathParameters(instance.mDesc, instance, sbsarParameters.parameters); +} +void +executeGraph(Renderer& renderer, SubstanceAir::GraphInstance& instance) +{ renderer.push(instance); TF_DEBUG(SBSAR_RENDER).Msg("SbsarRender: Starting rendering\n"); renderer.run(); renderer.flush(); TF_DEBUG(SBSAR_RENDER).Msg("SbsarRender: Done rendering\n"); +} + +void +collectAndStoreResults(GraphInstanceData& instanceData, + const ParsePathResult& sbsarParameters, + AssetCache& assetCache) +{ + SubstanceAir::GraphInstance& instance = instanceData.getGraphInstance(); // Local copy of sbsarParameters to adapt with the channel. ParsePathResult lastSbsarParameters = sbsarParameters; lastSbsarParameters.inputParameters = instanceData.getLastInputParameters(); diff --git a/sbsar/src/sbsarEngine/sbsarRender.h b/sbsar/src/sbsarEngine/sbsarRender.h index cadd8f13..10c72828 100644 --- a/sbsar/src/sbsarEngine/sbsarRender.h +++ b/sbsar/src/sbsarEngine/sbsarRender.h @@ -15,15 +15,23 @@ governing permissions and limitations under the License. #include namespace adobe::usd::sbsar { -//! \brief Start a rendering of the given graph instance with the given sbsar parameters. -//! Store all result in AssetCache. -//! \param renderer Substance renderer, must be unique. -//! \param instanceData Graph instance to renderer. -//! \param sbsarParameters Input parameters that will be set to the graph instance. -//! \param assetCache Cache where all the render's result are stored. +//! \brief Prepare a graph instance for rendering: patch output formats and apply input parameters. +//! Fast operation, no shared state access. Called under state->lock in the render thread. void USDSBSAR_API -renderGraph(SubstanceAir::Renderer& renderer, - GraphInstanceData& instanceData, - const ParsePathResult& sbsarParameters, - AssetCache& assetCache); +prepareGraph(SubstanceAir::Renderer& renderer, + GraphInstanceData& instanceData, + const ParsePathResult& sbsarParameters); + +//! \brief Execute the expensive substance rendering (push, run, flush). +//! No shared state access -- safe to call without holding state->lock. +void USDSBSAR_API +executeGraph(SubstanceAir::Renderer& renderer, SubstanceAir::GraphInstance& instance); + +//! \brief Collect render results from graph outputs and store them in the asset cache. +//! Accesses AssetCache for previous results (unchanged outputs) and stores new results. +//! Must be called while holding state->lock. +void USDSBSAR_API +collectAndStoreResults(GraphInstanceData& instanceData, + const ParsePathResult& sbsarParameters, + AssetCache& assetCache); } diff --git a/sbsar/src/sbsarEngine/sbsarRenderThread.cpp b/sbsar/src/sbsarEngine/sbsarRenderThread.cpp index ba703e65..dbab8461 100644 --- a/sbsar/src/sbsarEngine/sbsarRenderThread.cpp +++ b/sbsar/src/sbsarEngine/sbsarRenderThread.cpp @@ -27,9 +27,12 @@ governing permissions and limitations under the License. #include #include +#include #include #include #include +#include +#include #include PXR_NAMESPACE_USING_DIRECTIVE @@ -40,56 +43,227 @@ using namespace std::chrono_literals; //! Key : package path + parse result using RenderCacheKey = std::pair; +// --------------------------------------------------------------------------- +// Render thread lifetime — three invariants govern this file +// +// 1. Active drain on shutdown. The render thread parks in cv.wait_for(30s) +// when idle; teardown that just drops a pointer would block process +// exit for up to 30s. shutdown() sets stopRequested AND notify_all() so +// the thread reacts immediately. +// +// 2. Join before sibling statics tear down. The thread touches USD, +// SubstanceAir and the asset resolver — if their file-scope state has +// already been destroyed, the thread will use-after-free. The join is +// anchored to a function-local static (ShutdownAtExit) whose destructor +// runs LIFO before g_state and earlier-registered peers; see +// getRenderThreadState for the ordering proof. +// +// 3. The render thread is never the last strong holder of RenderThreadState. +// If it were, dropping the last ref on the render thread would run +// ~RenderThreadState there and try to join() itself. ShutdownAtExit +// pins a strong ref across join(); the thread itself carries only a +// weak_ptr that it upgrades per outer iteration. +// +// Two shortcuts have been considered and rejected: giving the render thread +// a shared_ptr (creates the self-join cycle in #3) and dropping g_state to +// let refcounting tear down (violates #1 and #2). Modify via shutdown() and +// the shared/weak split instead. +// +// Windows: a previous version wrapped renderThread in a no-op deleter so it +// was never joined — a guaranteed use-after-free masked by ExitProcess +// killing the thread. Joining at atexit fixes that, but runs under CRT +// shutdown, which can hold the loader lock. If a future change makes the +// render thread block on anything that itself needs the loader lock (DLL +// load, COM init, cross-thread GDI), the join will deadlock. There is no +// off-the-shelf alternative — USD does not provide a plugin-unload hook +// — so the constraint to honor when extending render-thread work is: +// nothing on the shutdown path may require the loader lock. Relatedly, +// ExitProcess can terminate the render thread mid-lock, leaving the +// underlying SRWLOCK orphaned; shutdown() uses try_lock to avoid +// deadlocking on it. +// --------------------------------------------------------------------------- + struct RenderThreadState { - std::shared_ptr renderThread; + std::thread renderThread; std::mutex lock; std::condition_variable cv; - bool shutDown = false; + std::atomic stopRequested{ false }; + std::atomic shutdownStarted{ false }; std::shared_ptr renderer; AssetCache assetCache; CacheStats cacheStats; SbsarConfigRefPtr config; std::map readRequests; - RenderThreadState(); + static std::shared_ptr create(); + + // Idempotent: sets stopRequested, wakes the render thread, and joins it. + // Safe to call from any thread other than the render thread itself. + void shutdown(); + ~RenderThreadState(); + +private: + RenderThreadState() = default; +}; + +void +renderThreadFn(std::weak_ptr weakState); + +namespace { + +std::mutex g_renderInitMutex; +// Sole strong reference owning the singleton. The render thread holds only a +// weak_ptr; consumers (requestRender/clearCache/getCacheStats) take a copy of +// this shared_ptr for the duration of their call so the state cannot be +// destroyed out from under them. +std::shared_ptr g_state; +// Once set, getRenderThreadState() returns nullptr. Prevents a USD worker +// thread from re-creating g_state during atexit teardown. +bool g_shuttingDown = false; + +// Drains the render thread at atexit. Registered as a function-local static +// in getRenderThreadState() so its destructor runs LIFO before g_state's. +// +// Ordering proof: g_state above is namespace-scope and its initializer is +// shared_ptr's default constructor, which is constexpr in C++17 — so g_state +// is constant-initialized during the static-init phase, before any dynamic +// initialization. ShutdownAtExit is a function-local static, so it registers +// with __cxa_atexit on first call to getRenderThreadState(), strictly later. +// LIFO destruction therefore guarantees this hook runs first, while +// USD/Substance/asset resolver are still alive — the safe window to join. +struct ShutdownAtExit +{ + ~ShutdownAtExit() + { + std::shared_ptr state; + { + std::lock_guard _l(g_renderInitMutex); + g_shuttingDown = true; + state = std::move(g_state); + } + if (state) { + // Drain the render thread synchronously. We still hold a strong + // reference here, so the render thread cannot be the last holder. + state->shutdown(); + } + // 'state' drops here. If no consumer thread still holds a reference, + // ~RenderThreadState runs now on the main thread; shutdown() is + // idempotent, so the destructor's call to it is a no-op. + } }; -std::mutex renderInitMutex; -std::unique_ptr g_state; +} // namespace -RenderThreadState* +std::shared_ptr getRenderThreadState() { + std::lock_guard _l(g_renderInitMutex); + if (g_shuttingDown) { + return nullptr; + } + if (!g_state) { + g_state = RenderThreadState::create(); + // First-use registration of the atexit drain — see ShutdownAtExit. + static const ShutdownAtExit shutdownHook; + (void)shutdownHook; + } + return g_state; +} + +std::shared_ptr +RenderThreadState::create() +{ + auto state = std::shared_ptr(new RenderThreadState()); + // Materialize the config before the render thread starts so it cannot + // race with first-use construction inside the loop. + state->config = getSbsarConfig(); + // weak_ptr (not shared_ptr) — see invariant #3 in the file header: a + // shared_ptr capture would let the render thread become the last holder + // and self-join inside ~RenderThreadState. + std::weak_ptr weak = state; + state->renderThread = std::thread(renderThreadFn, weak); + return state; +} + +void +RenderThreadState::shutdown() +{ + if (shutdownStarted.exchange(true)) { + return; + } + stopRequested = true; { - std::lock_guard _l(renderInitMutex); - if (!g_state) { - // Jumping through some hoops because of some kind of overridden - // deleter in SubstanceAir? - g_state = std::unique_ptr(new RenderThreadState(), - std::default_delete()); + // try_lock, not lock: normally serializes with a render-thread + // waiter mid-predicate-check to close the missed-wakeup window. + // On Windows, ExitProcess can kill the render thread mid-lock and + // orphan the SRWLOCK; a blocking acquire would deadlock. The worst + // case if we proceed without the lock is one missed wakeup, + // bounded by cv.wait_for's 30 s timeout. + std::unique_lock guard(lock, std::try_to_lock); + if (!guard.owns_lock()) { + TF_RUNTIME_ERROR("SbsarRenderThread: shutdown could not acquire state lock — " + "render thread likely terminated mid-lock by ExitProcess"); } } - return g_state.get(); + // Wake the render thread (and any requestRender() waiters) so they observe + // stopRequested and exit promptly, instead of sitting in cv.wait_for() for + // up to 30 s before join() can complete. + cv.notify_all(); + TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: Waiting for render thread to stop\n"); + if (renderThread.joinable()) { + renderThread.join(); + } else { + // Should be joinable from construction until we join it here. + TF_RUNTIME_ERROR("SbsarRenderThread: render thread is not joinable at shutdown"); + } + TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: Cleaning up renderer\n"); + // At this point no other thread of ours can touch state members. Clear the + // renderer explicitly so its (intentionally no-op-deleted) shared_ptr is + // released here rather than during arbitrary member-destruction order. + renderer.reset(); +} + +RenderThreadState::~RenderThreadState() +{ + TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: Releasing\n"); + shutdown(); } //! \brief Render thread function //! This function is the main loop of the render thread. It will wait for request from //! requestAsset(), render the asset and store the result in the AssetCache. +//! The lock is released during the expensive substance rendering to allow requesting +//! threads to consume results and avoid cache eviction of unconsumed entries. void -renderThreadFn() +renderThreadFn(std::weak_ptr weakState) { - try { - RenderThreadState* state = getRenderThreadState(); - TF_AXIOM(!state->renderer); - - while (!state->shutDown) { + bool firstIteration = true; + while (true) { + // Hold a strong reference only for the duration of one outer + // iteration. ShutdownAtExit pins its own reference across join(), so + // this thread is never the last holder — see RenderThreadState::shutdown. + std::shared_ptr state = weakState.lock(); + if (!state || state->stopRequested) { + return; + } + try { + if (firstIteration) { + TF_AXIOM(!state->renderer); + firstIteration = false; + } std::unique_lock guard(state->lock); - while (!state->readRequests.empty()) { + // INVARIANT: The lock (guard) must be held at the start and end of each + // iteration of this inner loop. The lock is temporarily released during + // executeGraph() for the expensive rendering, but must be re-acquired + // before any continue/break. Any new exit path must maintain this. + while (!state->readRequests.empty() && !state->stopRequested) { auto req = state->readRequests.begin(); - const ParsePathResult& parsePathResult = req->second; - const std::string& packagePath = req->first.first; + ParsePathResult parsePathResult = req->second; + std::string packagePath = req->first.first; + RenderCacheKey requestKey = req->first; + // Checking cache. Even if the cache check in // renderSbsarAsset failed, the texture might have been // prefetched at this point so we can skip rendering @@ -113,10 +287,43 @@ renderThreadFn() "was " "not prefetched yet\n", packagePath.c_str(), - req->first.second.c_str()); + requestKey.second.c_str()); std::shared_ptr instance = getGraphInstanceFromPackageCache(packagePath, parsePathResult); - renderGraph(*state->renderer, *instance, parsePathResult, state->assetCache); + + if (!instance) { + TF_RUNTIME_ERROR( + "SbsarRenderThread: Failed to get graph instance for %s, %s", + packagePath.c_str(), + requestKey.second.c_str()); + state->readRequests.erase(requestKey); + state->cv.notify_all(); + continue; + } + + // Prepare the graph instance (fast, under lock) + prepareGraph(*state->renderer, *instance, parsePathResult); + + // Release lock for the expensive rendering + guard.unlock(); + + try { + executeGraph(*state->renderer, instance->getGraphInstance()); + } catch (std::exception& e) { + TF_RUNTIME_ERROR("SbsarRenderThread: Render failed for %s: %s", + packagePath.c_str(), + e.what()); + guard.lock(); + state->readRequests.erase(requestKey); + state->cv.notify_all(); + continue; + } + + // Re-acquire lock to store results and update shared state + guard.lock(); + + // Collect results and store in cache (under lock) + collectAndStoreResults(*instance, parsePathResult, state->assetCache); } else { ++state->cacheStats.resultFoundInCache; TF_DEBUG(SBSAR_RENDER) @@ -124,28 +331,40 @@ renderThreadFn() "cache. Texture was " "prefetched\n", packagePath.c_str(), - req->first.second.c_str()); + requestKey.second.c_str()); } - TF_AXIOM(state->assetCache.hasRenderResult(parsePathResult)); - state->readRequests.erase(req); - // Give threads reading a chance to consume - // data before processing next request - // TODO: Can we be more granualar here + // Erase request AFTER result is in cache, so requesting threads + // can see their request is still pending during rendering + state->readRequests.erase(requestKey); state->cv.notify_all(); - state->cv.wait_for(guard, 0s); } TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: waiting for jobs\n"); - if (!state->shutDown) { + // Notify before sleeping so any stuck threads get a wakeup + state->cv.notify_all(); + if (!state->stopRequested) { state->cv.wait_for(guard, 30s); } TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: Renderthread waking up\n"); + } catch (std::exception& e) { + TF_RUNTIME_ERROR("SbsarRenderThread: Exception : %s", e.what()); + { + std::lock_guard guard(state->lock); + state->stopRequested = true; + } + state->cv.notify_all(); + return; + } catch (...) { + TF_RUNTIME_ERROR("SbsarRenderThread: Exception"); + { + std::lock_guard guard(state->lock); + state->stopRequested = true; + } + state->cv.notify_all(); + return; } - TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: Renderthread finishing\n"); - } catch (std::exception& e) { - TF_RUNTIME_ERROR("SbsarRenderThread: Exception : %s", e.what()); - } catch (...) { - TF_RUNTIME_ERROR("SbsarRenderThread: Exception"); + // 'state' shared_ptr drops here, bringing the refcount back down so + // that the owning ShutdownAtExit / consumer threads control destruction. } } @@ -196,12 +415,15 @@ requestRender(const std::string& packagePath, const std::string& packagedPath) return ResultType{}; } - RenderThreadState* state = getRenderThreadState(); + std::shared_ptr state = getRenderThreadState(); + if (!state) { + return ResultType{}; + } auto requestKey = std::make_pair(packagePath, packagedPath); { std::unique_lock guard(state->lock); // Checking for cached result - auto result = findResultInCache(parseOutput, state); + auto result = findResultInCache(parseOutput, state.get()); if (resultIsValid(result)) { TF_DEBUG(SBSAR_RENDER) .Msg("SbsarRenderThread: Found result in cache %s, %s\n", @@ -224,22 +446,58 @@ requestRender(const std::string& packagePath, const std::string& packagedPath) state->readRequests[requestKey] = parseOutput; } state->cv.notify_all(); + + constexpr auto kWaitTimeout = 5s; + constexpr int kMaxRetries = 12; // 1 minute total at 5s per timeout + int retries = 0; + while (true) { - state->cv.wait(guard); - result = findResultInCache(parseOutput, state); + state->cv.wait_for(guard, kWaitTimeout); + + // Check for shutdown + if (state->stopRequested) { + TF_WARN("SbsarRenderThread: Shutdown while waiting for %s, %s", + packagePath.c_str(), + packagedPath.c_str()); + return ResultType{}; + } + + result = findResultInCache(parseOutput, state.get()); if (resultIsValid(result)) { TF_DEBUG(SBSAR_RENDER) .Msg("SbsarRenderThread: Result send to hydra %s, %s\n", packagePath.c_str(), packagedPath.c_str()); return result; - } else if (resultExistInTheOtherCache(parseOutput, state)) { + } else if (resultExistInTheOtherCache(parseOutput, state.get())) { TF_WARN("SbsarRenderThread: the requested result is not of the right type (VtValue " "or ArAsset): %s, %s\n", packagePath.c_str(), packagedPath.c_str()); return ResultType{}; } + + // If the request is still pending, the render thread hasn't finished yet + if (state->readRequests.find(requestKey) != state->readRequests.end()) { + continue; + } + + // Request was processed but result not in cache (evicted). + // Re-submit the request. + ++retries; + if (retries > kMaxRetries) { + TF_RUNTIME_ERROR("SbsarRenderThread: Exceeded max retries waiting for %s, %s", + packagePath.c_str(), + packagedPath.c_str()); + return ResultType{}; + } + TF_WARN("SbsarRenderThread: Result evicted before consumption, " + "re-submitting request (retry %d) for %s, %s", + retries, + packagePath.c_str(), + packagedPath.c_str()); + state->readRequests[requestKey] = parseOutput; + state->cv.notify_all(); } } } @@ -260,52 +518,29 @@ renderSbsarValue(const std::string& packagePath, const std::string& packagedPath void clearCache() { - RenderThreadState* state = getRenderThreadState(); - { - std::unique_lock guard(state->lock); - state->cacheStats = CacheStats(); - state->assetCache.clearCache(); - clearInputImageCache(); - clearPackageCache(); + std::shared_ptr state = getRenderThreadState(); + if (!state) { + return; } + std::unique_lock guard(state->lock); + state->cacheStats = CacheStats(); + state->assetCache.clearCache(); + clearInputImageCache(); + clearPackageCache(); } CacheStats& getCacheStats() { - RenderThreadState* state = getRenderThreadState(); + // Test-only accessor. The returned reference is valid for as long as the + // singleton lives, which is until atexit. Tests must not retain it past + // process shutdown. + std::shared_ptr state = getRenderThreadState(); + static CacheStats sEmptyStats; + if (!state) { + return sEmptyStats; + } return state->cacheStats; } -RenderThreadState::RenderThreadState() -{ -#ifdef _WIN32 - // Remove destructor call - // since threads are killed before static data - // is released on windows - renderThread = - std::shared_ptr(new std::thread(renderThreadFn), [](std::thread*) {}); -#else // _WIN32 - renderThread = std::make_unique(renderThreadFn); -#endif // _WIN32 - // Remove destruction to "work around" lock at end - // Leaving Renderer uninitialized to make sure the renderer is created - // by the render thread to avoid GL context issues - - // Get config to ensure it exist at the beginning of the render thread. - config = getSbsarConfig(); -} -RenderThreadState::~RenderThreadState() -{ - TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: Releasing\n"); - std::unique_lock guard(lock); - shutDown = true; - guard.unlock(); - cv.notify_all(); - TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: Waiting for render thread to stop\n"); - renderThread->join(); - TF_DEBUG(SBSAR_RENDER).Msg("SbsarRenderThread: Cleaning up renderer\n"); - renderer.reset(); -} - } // namespace adobe::usd::sbsar diff --git a/sbsar/src/sbsarfileformat.cpp b/sbsar/src/sbsarfileformat.cpp index 085b2f80..c95370e8 100644 --- a/sbsar/src/sbsarfileformat.cpp +++ b/sbsar/src/sbsarfileformat.cpp @@ -446,10 +446,13 @@ SBSARFileFormat::ComposeFieldsForFileFormatArguments(const std::string& assetPat TfStringify(paramValue).c_str()); if (parameter->isImage()) { const auto& imageAssetPath = paramValue.Get(); - std::string resolvedImageAssetPath = - resolveSbsarImageInputAssetPath(imageAssetPath, resolvedSbsarPath, parameterName); - std::size_t hash = addImageToInputImageCache(resolvedImageAssetPath); - dict[parameterName] = VtValue(hash); + // Empty value means no image is connected + if (!imageAssetPath.GetAssetPath().empty()) { + std::string resolvedImageAssetPath = resolveSbsarImageInputAssetPath( + imageAssetPath, resolvedSbsarPath, parameterName); + std::size_t hash = addImageToInputImageCache(resolvedImageAssetPath); + dict[parameterName] = VtValue(hash); + } } else { // Color values in USD are in linear space, but color inputs for a Substance graph // are (usually) in sRGB space. So we convert the incoming value from USD to sRGB diff --git a/sbsar/src/usdGeneration/dictEncoder.cpp b/sbsar/src/usdGeneration/dictEncoder.cpp index 7452bb22..56dff463 100644 --- a/sbsar/src/usdGeneration/dictEncoder.cpp +++ b/sbsar/src/usdGeneration/dictEncoder.cpp @@ -120,19 +120,19 @@ readDict(std::istream& input) if (a[0].IsInt()) { if (sz == 2) { GfVec2i res{}; - for (int i = 0; i < sz; ++i) { + for (size_t i = 0; i < sz; ++i) { res[i] = static_cast(a[i].GetInt()); } d[oi.first] = VtValue(res); } else if (sz == 3) { GfVec3i res{}; - for (int i = 0; i < sz; ++i) { + for (size_t i = 0; i < sz; ++i) { res[i] = static_cast(a[i].GetInt()); } d[oi.first] = VtValue(res); } else { GfVec4i res{}; - for (int i = 0; i < sz; ++i) { + for (size_t i = 0; i < sz; ++i) { res[i] = static_cast(a[i].GetInt()); } d[oi.first] = VtValue(res); @@ -140,19 +140,19 @@ readDict(std::istream& input) } else { if (sz == 2) { GfVec2f res{}; - for (int i = 0; i < sz; ++i) { + for (size_t i = 0; i < sz; ++i) { res[i] = static_cast(a[i].GetReal()); } d[oi.first] = VtValue(res); } else if (sz == 3) { GfVec3f res{}; - for (int i = 0; i < sz; ++i) { + for (size_t i = 0; i < sz; ++i) { res[i] = static_cast(a[i].GetReal()); } d[oi.first] = VtValue(res); } else { GfVec4f res{}; - for (int i = 0; i < sz; ++i) { + for (size_t i = 0; i < sz; ++i) { res[i] = static_cast(a[i].GetReal()); } d[oi.first] = VtValue(res); diff --git a/sbsar/src/usdGeneration/sbsarMaterial.cpp b/sbsar/src/usdGeneration/sbsarMaterial.cpp index d1d5dc51..b6af44fe 100644 --- a/sbsar/src/usdGeneration/sbsarMaterial.cpp +++ b/sbsar/src/usdGeneration/sbsarMaterial.cpp @@ -76,8 +76,9 @@ initDefaultMaterialInputs(SdfAbstractData* sdfData, NormalFormat normalFormat = getDefaultNormalFormat(graphDesc); - for (const auto& usage : mapped_usages) { - if (hasUsage(usage, graphDesc)) { + const auto& usages = isOpenPbrNativeGraph(graphDesc) ? mapped_usages_openpbr : mapped_usages; + for (const auto& usage : usages) { + if (hasImageUsage(usage, graphDesc)) { std::string textureAssetName = getTextureAssetName(usage); SdfPath textureAssetPath = createShaderInput(sdfData, materialPath, textureAssetName, SdfValueTypeNames->Asset); @@ -129,8 +130,9 @@ setMaterialTexturePaths(SdfAbstractData* sdfData, const JsValue& jsParams) { TF_DEBUG(FILE_FORMAT_SBSAR).Msg("setMaterialTexturePaths\n"); - for (const auto& usage : mapped_usages) { - if (hasUsage(usage, graphDesc)) { + const auto& usages = isOpenPbrNativeGraph(graphDesc) ? mapped_usages_openpbr : mapped_usages; + for (const auto& usage : usages) { + if (hasImageUsage(usage, graphDesc)) { std::string textureAssetName = getTextureAssetName(usage); SdfPath textureAssetPath = createShaderInput(sdfData, materialPath, textureAssetName, SdfValueTypeNames->Asset); @@ -154,21 +156,25 @@ setMaterialValues(SdfAbstractData* sdfData, const std::string& packagePath) { TF_DEBUG(FILE_FORMAT_SBSAR).Msg("setMaterialOutputValues\n"); - for (const auto& usage : uniform_usages) { - if (hasUsage(usage, graphDesc)) { - auto defaultIt = default_channels.find(usage); - if (defaultIt != default_channels.end()) { - std::string textureAssetName = usage; - SdfPath textureAssetPath = createShaderInput( - sdfData, materialPath, textureAssetName, defaultIt->second.type); - std::string infoPath = generateSbsarInfoPath(usage, graphName, sbsarHash, jsParams); - TF_DEBUG(FILE_FORMAT_SBSAR) - .Msg("Using engine to get value for %s\n", usage.c_str()); - setAttributeDefaultValue(sdfData, - textureAssetPath, - renderSbsarValue(packagePath, infoPath), - defaultIt->second.type); - } + const auto& usages = isOpenPbrNativeGraph(graphDesc) ? mapped_usages_openpbr : uniform_usages; + for (const auto& usage : usages) { + auto [hasUsage, iotype] = getUsageAndSubstanceType(usage, graphDesc); + if (!hasUsage) + continue; + + if (iotype == SubstanceIOType::Substance_IOType_Image || + iotype == SubstanceIOType::Substance_IOType_String || + iotype == SubstanceIOType::Substance_IOType_Font) { + continue; + } + + auto defaultIt = default_channels.find(usage); + if (defaultIt != default_channels.end()) { + SdfPath inputPath = + createShaderInput(sdfData, materialPath, usage, defaultIt->second.type); + std::string infoPath = generateSbsarInfoPath(usage, graphName, sbsarHash, jsParams); + setAttributeDefaultValue( + sdfData, inputPath, renderSbsarValue(packagePath, infoPath), defaultIt->second.type); } } } @@ -209,7 +215,7 @@ setMaterialNormalScaleAndBias(SdfAbstractData* sdfData, materialPathStr.c_str()); setAttributeDefaultValue(sdfData, scaleAttrPath, scale, SdfValueTypeNames->Float4); setAttributeDefaultValue(sdfData, biasAttrPath, bias, SdfValueTypeNames->Float4); - // XXX @dcoffey There's a gap here with OpenPBR which doesn't use these input + // XXX There's a gap here with OpenPBR which doesn't use these input // connections as it sets the scale and bias directly in the Shader node. This means // that if the normal format is changed via the sbsar parameters, the change won't be // impact the scale / bias. It's a little abmbigous what the user goal of toggling the @@ -255,7 +261,8 @@ void addStandardMaterial(SdfAbstractData* sdfData, const SdfPath& materialPath, const SubstanceAir::GraphDesc& graphDesc, - const SBSAROptions& options) + const SBSAROptions& options, + bool hasScatter) { #ifdef USDSBSAR_ENABLE_TEXTURE_TRANSFORM addMaterialTransform(sdfData, materialPath); @@ -299,7 +306,7 @@ addStandardMaterial(SdfAbstractData* sdfData, // Add Refractive MaterialX Implementation if (options.writeOpenPBR) { NormalFormat initialNormalFormat = getDefaultNormalFormat(graphDesc); - addOpenPbrShader(sdfData, materialPath, graphDesc, initialNormalFormat); + addOpenPbrShader(sdfData, materialPath, graphDesc, initialNormalFormat, hasScatter); } } @@ -319,6 +326,20 @@ addMaterialPrim(SdfAbstractData* sdfData, { TF_DEBUG(FILE_FORMAT_SBSAR).Msg("addMaterialPrim: Depth: %i\n", sbsarData.depth); + // Determine scatter state using default params so the shader graph can be wired correctly. + // This uses the sbsar default value since the graph structure (depth==0) must be fixed before + // any per-instance sbsarParameters are available. + bool hasScatter = false; + if (hasUsage("scatter", graphDesc)) { + JsValue defaultParams = convertSbsarParameters({}); + std::string infoPath = + generateSbsarInfoPath("scatter", graphName, sbsarHash, defaultParams); + VtValue scatterVal = renderSbsarValue(packagePath, infoPath); + if (scatterVal.IsHolding()) { + hasScatter = scatterVal.UncheckedGet(); + } + } + const SdfPath rootPath = SdfPath::AbsoluteRootPath(); SdfPath materialPath; if (sbsarData.depth == 0) { @@ -344,7 +365,7 @@ addMaterialPrim(SdfAbstractData* sdfData, // yet. initDefaultMaterialInputs(sdfData, refMaterialPath, graphDesc, graphName, sbsarHash); // Create all the different material networks - addStandardMaterial(sdfData, refMaterialPath, graphDesc, sbsarData); + addStandardMaterial(sdfData, refMaterialPath, graphDesc, sbsarData, hasScatter); // Now create the actual material prim that references the prototype // This makes sure the opinions in the protoype are weaker than in the variants and the @@ -383,11 +404,15 @@ addMaterialPrim(SdfAbstractData* sdfData, // We assume opengl in the initial state, but the substance engine assumes directx, this // will tell the engine to use opengl formatting jsParams = applyDefaultNormalFormatInput(graphDesc, jsParams); - // Set the procedural texture paths based on the sbsarParameters - setMaterialTexturePaths(sdfData, materialPath, graphDesc, graphName, sbsarHash, jsParams); - // Set procedural values for uniform usage + + // Set procedural values for uniform usage. We do this before setting the texture paths, as + // some of the procedural values might be used in the generation of the procedural texture + // asset paths. setMaterialValues( sdfData, materialPath, graphDesc, graphName, sbsarHash, jsParams, packagePath); + + // Set the procedural texture paths based on the sbsarParameters + setMaterialTexturePaths(sdfData, materialPath, graphDesc, graphName, sbsarHash, jsParams); // Set normal scale and bias depending on the normal format setMaterialNormalScaleAndBias(sdfData, materialPath, graphDesc, jsParams, sbsarData); } diff --git a/sbsar/src/usdGeneration/sbsarMaterial.h b/sbsar/src/usdGeneration/sbsarMaterial.h index 71d59697..dfe1146b 100644 --- a/sbsar/src/usdGeneration/sbsarMaterial.h +++ b/sbsar/src/usdGeneration/sbsarMaterial.h @@ -14,6 +14,7 @@ governing permissions and limitations under the License. #include "sbsarSymbolMapper.h" #include +#include #include #include diff --git a/sbsar/src/usdGeneration/sbsarOpenPBR.cpp b/sbsar/src/usdGeneration/sbsarOpenPBR.cpp index 87f482a8..697d6e25 100644 --- a/sbsar/src/usdGeneration/sbsarOpenPBR.cpp +++ b/sbsar/src/usdGeneration/sbsarOpenPBR.cpp @@ -48,6 +48,7 @@ struct BindInfo SdfValueTypeName sdfType; std::string outputName; TfToken colorSpace; + GfVec4f scale; }; // This is a mapping from SBSAR usage to OpenPBR inputs @@ -66,109 +67,502 @@ struct BindInfo // * Maybe we need an explicit color conversion. The colorSpace is currently not considered static std::map _materialMapBindings = { // * Base - // base_weight (no source info) + // base_weight (no ASM source info) { "baseColor", - { OpenPbrTokens->base_color, SdfValueTypeNames->Color3f, "out", AdobeTokens->sRGB } }, - // base_diffuse_roughness (no source info) see above + { OpenPbrTokens->base_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + // ambient occlusion will be handled with a custom shader graph since OpenPBR does not have a + // dedicated input for it + { "ambientOcclusion", + { AsmTokens->ambientOcclusion, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + // base_diffuse_roughness (no ASM source info) { "metallic", - { OpenPbrTokens->base_metalness, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { OpenPbrTokens->base_metalness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, // * Specular + + // specular_weight = 2.0 * specularLevel so we specify a non-unit scale value that is applied to + // the texture input in the shader graph that specularLevel is connected to. { "specularLevel", - { OpenPbrTokens->specular_weight, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { OpenPbrTokens->specular_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + GfVec4f(2.0f, 2.0f, 2.0f, 1.0f) } }, { "specularEdgeColor", - { OpenPbrTokens->specular_color, SdfValueTypeNames->Color3f, "out", AdobeTokens->sRGB } }, + { OpenPbrTokens->specular_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, { "roughness", - { OpenPbrTokens->specular_roughness, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, - // specular_ior (no source info) - // XXX does this work? - //{ "IOR", { OpenPbrTokens->specular_ior, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { OpenPbrTokens->specular_roughness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + // specular_ior (no ASM source info) { "anisotropyLevel", { OpenPbrTokens->specular_roughness_anisotropy, SdfValueTypeNames->Float, "out", - AdobeTokens->raw } }, + AdobeTokens->raw, + kDefaultTexScale } }, // * Transmission { "translucency", - { OpenPbrTokens->transmission_weight, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { OpenPbrTokens->transmission_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, { "absorptionColor", - { OpenPbrTokens->transmission_color, SdfValueTypeNames->Color3f, "out", AdobeTokens->sRGB } }, - // transmission_depth (no source info) (absorption distance?) - // transmission_scatter (no source info) - // transmission_scatter_anisotropy (no source info) - // transmission_dispersion_scale (no source info) - // transmission_dispersion_abbe_number (no source info) + { OpenPbrTokens->transmission_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + // transmission_depth (no ASM source info) + // transmission_scatter (no ASM source info) + // transmission_scatter_anisotropy (no ASM source info) + // transmission_dispersion_scale (no ASM source info) + // transmission_dispersion_abbe_number (no ASM source info) // * Subsurface - // subsurface_weight (no source info) (is set to 1 if we have scatterng color or distance scale) + // subsurface_weight (no ASM source info) (is set to 1 if we have scattering color or distance + // scale) { "scatteringColor", - { OpenPbrTokens->transmission_scatter, + { OpenPbrTokens->subsurface_color, SdfValueTypeNames->Color3f, "out", - AdobeTokens->sRGB } }, + AdobeTokens->sRGB, + kDefaultTexScale } }, { "scatteringDistanceScale", { OpenPbrTokens->subsurface_radius_scale, SdfValueTypeNames->Color3f, "out", - AdobeTokens->sRGB } }, - // subsurface_radius_scale (no source info) (maps to ASM scatteringDistanceScale) - // subsurface_anisotropy (no source info) - // subsurface_scatter_anisotropy (no source info) + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "scatteringDistance", + { OpenPbrTokens->subsurface_radius, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + // subsurface_scatter_anisotropy (no ASM source info) // * Fuzz { "sheenOpacity", - { OpenPbrTokens->fuzz_weight, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { OpenPbrTokens->fuzz_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, { "sheenColor", - { OpenPbrTokens->fuzz_color, SdfValueTypeNames->Color3f, "out", AdobeTokens->sRGB } }, + { OpenPbrTokens->fuzz_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, { "sheenRoughness", - { OpenPbrTokens->fuzz_roughness, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { OpenPbrTokens->fuzz_roughness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, // * Coat { "coatOpacity", - { OpenPbrTokens->coat_weight, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { OpenPbrTokens->coat_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, { "coatColor", - { OpenPbrTokens->coat_color, SdfValueTypeNames->Color3f, "out", AdobeTokens->sRGB } }, + { OpenPbrTokens->coat_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, { "coatRoughness", - { OpenPbrTokens->coat_roughness, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, - // coat_roughness_anisotropy (no source info) - // coat_ior (no source info) - // coat_darkening (no source info) + { OpenPbrTokens->coat_roughness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + // coat_roughness_anisotropy (no ASM source info) + // coat_ior (no ASM source info) + // coat_darkening (no ASM source info) // * Thin film - // thin_film_weight (no source info) - // thin_film_thickness (no source info) - // thin_film_ior (no source info) + // thin_film_weight (no ASM source info) + // thin_film_thickness (no ASM source info) + // thin_film_ior (no ASM source info) // * Emission - // emission_luminance (no source info) (is set to 1000 if we have "emissive" input) + // emission_luminance (no ASM source info) (is set to 1000 if we have "emissive" input) { "emissive", - { OpenPbrTokens->emission_color, SdfValueTypeNames->Color3f, "out", AdobeTokens->sRGB } }, + { OpenPbrTokens->emission_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + + // * Displacement + // height, heightLevel and heightScale are sbs inputs that have the same names as ASM inputs + // but not OpenPBR native. We keep the ASM naming of the material inputs which are connected + // to a seperate displacement shader. + { "height", + { TfToken("height"), SdfValueTypeNames->Float, "out", AdobeTokens->raw, kDefaultTexScale } }, + { "heightLevel", + { TfToken("heightLevel"), + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "heightScale", + { TfToken("heightScale"), + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + + // * Geometry + { "opacity", + { OpenPbrTokens->geometry_opacity, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "normal", + { OpenPbrTokens->geometry_normal, + SdfValueTypeNames->Float3, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + // tangent is mapped to geometry_tangent + { "tangent", + { OpenPbrTokens->geometry_tangent, + SdfValueTypeNames->Float3, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "coatNormal", + { OpenPbrTokens->geometry_coat_normal, + SdfValueTypeNames->Float3, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + // geometry_coat_tangent (no ASM source info) +}; + +// Identity mapping table for sbsar graphs authored with the OpenPBR material model. +// Each entry maps an OpenPBR output usage name directly to the same OpenPBR shader input. +// Note: specular_weight uses no scale factor — unlike the ASM "specularLevel" mapping +// (which applies 2x), a native OpenPBR sbsar already outputs values in the expected range. +static const std::map _openPbrNativeMapBindings = { + // * Base + { "baseWeight", + { OpenPbrTokens->base_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "baseColor", + { OpenPbrTokens->base_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "baseDiffuseRoughness", + { OpenPbrTokens->base_diffuse_roughness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "metallic", + { OpenPbrTokens->base_metalness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + // ambient occlusion will be handled with a custom shader graph since OpenPBR does not have a + // dedicated input for it + { "ambientOcclusion", + { AsmTokens->ambientOcclusion, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + + // * Specular + { "specularWeight", + { OpenPbrTokens->specular_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "specularColor", + { OpenPbrTokens->specular_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "specularRoughness", + { OpenPbrTokens->specular_roughness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "specularIOR", + { OpenPbrTokens->specular_ior, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "specularRoughnessAnisotropy", + { OpenPbrTokens->specular_roughness_anisotropy, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + + // * Transmission + { "transmissionWeight", + { OpenPbrTokens->transmission_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "transmissionColor", + { OpenPbrTokens->transmission_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "transmissionDepth", + { OpenPbrTokens->transmission_depth, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "transmissionScatter", + { OpenPbrTokens->transmission_scatter, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "transmissionScatterAnisotropy", + { OpenPbrTokens->transmission_scatter_anisotropy, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "transmissionDispersionScale", + { OpenPbrTokens->transmission_dispersion_scale, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "transmissionDispersionAbbeNumber", + { OpenPbrTokens->transmission_dispersion_abbe_number, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + + // * Subsurface + { "subsurfaceWeight", + { OpenPbrTokens->subsurface_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "subsurfaceColor", + { OpenPbrTokens->subsurface_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "subsurfaceRadius", + { OpenPbrTokens->subsurface_radius, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "subsurfaceRadiusScale", + { OpenPbrTokens->subsurface_radius_scale, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "subsurfaceScatterAnisotropy", + { OpenPbrTokens->subsurface_scatter_anisotropy, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + + // * Coat + { "coatWeight", + { OpenPbrTokens->coat_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "coatColor", + { OpenPbrTokens->coat_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "coatRoughness", + { OpenPbrTokens->coat_roughness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "coatRoughnessAnisotropy", + { OpenPbrTokens->coat_roughness_anisotropy, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "coatIOR", + { OpenPbrTokens->coat_ior, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "coatDarkening", + { OpenPbrTokens->coat_darkening, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + + // * Fuzz + { "fuzzWeight", + { OpenPbrTokens->fuzz_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "fuzzColor", + { OpenPbrTokens->fuzz_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, + { "fuzzRoughness", + { OpenPbrTokens->fuzz_roughness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + + // * Emission + { "emissionLuminance", + { OpenPbrTokens->emission_luminance, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "emissionColor", + { OpenPbrTokens->emission_color, + SdfValueTypeNames->Color3f, + "out", + AdobeTokens->sRGB, + kDefaultTexScale } }, // * Displacement // height, heightLevel and heightScale are sbs inputs that have the same names as ASM inputs // but not OpenPBR native. We keep the ASM naming of the material inputs which are connected // to a seperate displacement shader. - { "height", { TfToken("height"), SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { "height", + { TfToken("height"), SdfValueTypeNames->Float, "out", AdobeTokens->raw, kDefaultTexScale } }, { "heightLevel", - { TfToken("heightLevel"), SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { TfToken("heightLevel"), + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, { "heightScale", - { TfToken("heightScale"), SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { TfToken("heightScale"), + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + + // * Thin Film + { "thinFilmWeight", + { OpenPbrTokens->thin_film_weight, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "thinFilmThickness", + { OpenPbrTokens->thin_film_thickness, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "thinFilmIOR", + { OpenPbrTokens->thin_film_ior, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, // * Geometry { "opacity", - { OpenPbrTokens->geometry_opacity, SdfValueTypeNames->Float, "out", AdobeTokens->raw } }, + { OpenPbrTokens->geometry_opacity, + SdfValueTypeNames->Float, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, { "normal", - { OpenPbrTokens->geometry_normal, SdfValueTypeNames->Float3, "out", AdobeTokens->raw } }, + { OpenPbrTokens->geometry_normal, + SdfValueTypeNames->Float3, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, { "coatNormal", - { OpenPbrTokens->geometry_coat_normal, SdfValueTypeNames->Float3, "out", AdobeTokens->raw } }, - // geometry_tangent (no source info) (derive from anisotropyAngle?) - // geometry_coat_tangent (no source info) + { OpenPbrTokens->geometry_coat_normal, + SdfValueTypeNames->Float3, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "tangent", + { OpenPbrTokens->geometry_tangent, + SdfValueTypeNames->Float3, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, + { "coatTangent", + { OpenPbrTokens->geometry_coat_tangent, + SdfValueTypeNames->Float3, + "out", + AdobeTokens->raw, + kDefaultTexScale } }, }; static const std::string heightStr = "height"; static const std::string heightLevelStr = "heightLevel"; static const std::string heightScaleStr = "heightScale"; +static const std::string baseColorStr = "baseColor"; +static const std::string ambientOcclusionStr = "ambientOcclusion"; SdfPath bindTexture(SdfAbstractData* sdfData, @@ -199,6 +593,9 @@ bindTexture(SdfAbstractData* sdfData, input.bias = kOpenGLNormalTexBias; } } + if (bindInfo.scale != kDefaultTexScale) { + input.scale = bindInfo.scale; + } if (bindInfo.sdfType == SdfValueTypeNames->Color3f) { input.channel = AdobeTokens->rgb; @@ -222,8 +619,10 @@ bool addUsdOpenPbrShaderImpl(SdfAbstractData* sdfData, const SdfPath& materialPath, const GraphDesc& graphDesc, + const std::vector& usages, const std::map& mapBindings, - const NormalFormat& initialNormalFormat) + const NormalFormat& initialNormalFormat, + bool hasScatter) { TF_DEBUG(FILE_FORMAT_SBSAR) .Msg("addUsdOpenPbrShaderImpl: Adding OpenPBR/MaterialX Implementation\n"); @@ -295,30 +694,82 @@ addUsdOpenPbrShaderImpl(SdfAbstractData* sdfData, return path; }; - SdfPath heightLevelAttrPath; - SdfPath heightScaleAttrPath; - if (hasUsage(heightStr, graphDesc)) { - heightLevelAttrPath = createMaterialInput(heightLevelStr, 0.5f); - heightScaleAttrPath = createMaterialInput(heightScaleStr, 1.0f); - } - // Create texture sampling nodes InputValues inputValues; InputConnections inputConnections; bool enableSubsurface = false; - for (const auto& usage : mapped_usages) { - if (hasUsage(usage, graphDesc)) { + for (const auto& usage : usages) { + if (hasImageUsage(usage, graphDesc)) { if (usage == heightLevelStr || usage == heightScaleStr) { // these are handled above when "height" is present so skip continue; } + if (usage == ambientOcclusionStr) { + // ambient occlusion is handled below when baseColor is present + continue; + } auto it = mapBindings.find(usage); if (it != mapBindings.end()) { - const BindInfo& bindInfo = it->second; + // translucency is normally mapped to transmission_weight but when hasScatter is + // true, map to subsurface_weight. + BindInfo bindInfo = it->second; + if (hasScatter && usage == "translucency") { + bindInfo.name = OpenPbrTokens->subsurface_weight; + } SdfPath texResultPath = createTextureReader(usage, bindInfo); - if (usage == heightStr) { + if (usage == baseColorStr) { + + // If we also have ambient occlusion, we convert the ambient occlusion float to + // a color3 and then combine the base color and ambient occlusion together with + // an ND_mix_color3 shader and connect the result to the base color input of the + // OpenPBR shader. + + // create a texture node for ambient occlusion + SdfPath ambientOcclusionAttrPath; + if (hasUsage(ambientOcclusionStr, graphDesc)) { + auto it = mapBindings.find(ambientOcclusionStr); + if (it != mapBindings.end()) { + const BindInfo& bindInfo = it->second; + ambientOcclusionAttrPath = + createTextureReader(ambientOcclusionStr, bindInfo); + } + } + if (!ambientOcclusionAttrPath.IsEmpty()) { + // convert ambientOcclusion float to color3 + SdfPath occlusionColorOutput = + createShader(sdfData, + scopePath, + AdobeTokens->AmbientOcclusionAsColor, + MtlXTokens->ND_convert_float_color3, + "out", + {}, + { { "in", ambientOcclusionAttrPath } }); + + // Provide base_color and occlusion color as inputs to the ND_mix_color3 + // node. Note: We use a fixed value of 0.0 for the "mix" input which means + // that the "bg" input is connected to the base color source and "fg" is + // connected to the ambient occlusion source. + SdfPath ambientOcclusionBaseColor = + createShader(sdfData, + scopePath, + AdobeTokens->AmbientOcclusionBaseColor, + MtlXTokens->ND_mix_color3, + "out", + { { "mix", 0.0f } }, + { { "bg", texResultPath }, { "fg", occlusionColorOutput } }); + + // replace the original base color texture result with the output of the + // ambient occlusion mix node + texResultPath = ambientOcclusionBaseColor; + } + inputConnections.emplace_back(bindInfo.name.GetString(), texResultPath); + + } else if (usage == heightStr) { + + SdfPath heightLevelAttrPath = createMaterialInput(heightLevelStr, 0.5f); + SdfPath heightScaleAttrPath = createMaterialInput(heightScaleStr, 1.0f); SdfPath heightLevel = createShader(sdfData, scopePath, @@ -362,21 +813,29 @@ addUsdOpenPbrShaderImpl(SdfAbstractData* sdfData, } } - if (enableSubsurface) { + if (enableSubsurface && !hasScatter) { inputValues.emplace_back(OpenPbrTokens->subsurface_weight, 1.0f); } -// TODO: build mapping table for uniform values from the SBSAR usages to the corresponding -// inputs for OpenPBR. E.g. IOR -> specular_ior, etc. -#if 0 // Connect to uniform values - for (auto& usage : uniform_usages) { - if (hasUsage(usage, graphDesc)) { - SdfPath uniformAttrPath = inputPath(materialPath, usage); - inputConnections.emplace_back(usage, uniformAttrPath); + for (auto& usage : usages) { + auto [hasUsage, iotype] = getUsageAndSubstanceType(usage, graphDesc); + if (hasUsage) { + + TF_DEBUG(FILE_FORMAT_SBSAR).Msg("uniform: %s\n", usage.c_str()); + if (iotype == SubstanceIOType::Substance_IOType_Image || + iotype == SubstanceIOType::Substance_IOType_String || + iotype == SubstanceIOType::Substance_IOType_Font) { + continue; + } + auto it = mapBindings.find(usage); + if (it != mapBindings.end()) { + const BindInfo& bindInfo = it->second; + SdfPath attrPath = inputPath(materialPath, usage); + inputConnections.emplace_back(bindInfo.name.GetString(), attrPath); + } } } -#endif // Create MaterialX shader for Adobe Standard Material SdfPath surfaceOutputPath = createShader(sdfData, @@ -400,10 +859,25 @@ bool addOpenPbrShader(SdfAbstractData* sdfData, const SdfPath& materialPath, const SubstanceAir::GraphDesc& graphDesc, - const NormalFormat& initialNormalFormat) + const NormalFormat& initialNormalFormat, + bool hasScatter) { - return addUsdOpenPbrShaderImpl( - sdfData, materialPath, graphDesc, _materialMapBindings, initialNormalFormat); + if (isOpenPbrNativeGraph(graphDesc)) { + return addUsdOpenPbrShaderImpl(sdfData, + materialPath, + graphDesc, + mapped_usages_openpbr, + _openPbrNativeMapBindings, + initialNormalFormat, + hasScatter); + } + return addUsdOpenPbrShaderImpl(sdfData, + materialPath, + graphDesc, + mapped_usages, + _materialMapBindings, + initialNormalFormat, + hasScatter); } } diff --git a/sbsar/src/usdGeneration/sbsarOpenPBR.h b/sbsar/src/usdGeneration/sbsarOpenPBR.h index ce5f6372..1bac83a3 100644 --- a/sbsar/src/usdGeneration/sbsarOpenPBR.h +++ b/sbsar/src/usdGeneration/sbsarOpenPBR.h @@ -28,11 +28,13 @@ namespace adobe::usd::sbsar { /// @param sdfData SDF data container to store the material in /// @param materialPath Path of the parent material /// @param graphDesc Description of the current SBSAR graph +/// @param hasScatter Whether the scatter output is enabled (affects translucency routing) /// @return true if the material was successfully added, false otherwise bool addOpenPbrShader(PXR_NS::SdfAbstractData* sdfData, const PXR_NS::SdfPath& materialPath, const SubstanceAir::GraphDesc& graphDesc, - const NormalFormat& initialNormalFormat); + const NormalFormat& initialNormalFormat, + bool hasScatter); } diff --git a/sbsar/src/usdGeneration/usdGenerationHelpers.cpp b/sbsar/src/usdGeneration/usdGenerationHelpers.cpp index 39927b71..183250c7 100644 --- a/sbsar/src/usdGeneration/usdGenerationHelpers.cpp +++ b/sbsar/src/usdGeneration/usdGenerationHelpers.cpp @@ -57,10 +57,13 @@ const std::vector mapped_usages = { "roughness", "metallic", "normal", + "tangent", "opacity", "refraction", "emissive", "height", + "heightLevel", + "heightScale", "specularLevel", "specularEdgeColor", "anisotropyLevel", @@ -74,9 +77,61 @@ const std::vector mapped_usages = { "coatRoughness", "coatSpecularLevel", "translucency", + "scatteringDistance", "scatteringDistanceScale", "scatteringColor", }; + +// Output usage names for sbsar graphs authored with the OpenPBR material model. +// These use OpenPBR parameter names directly as output usage identifiers. +const std::vector mapped_usages_openpbr = { + "baseWeight", + "baseColor", + "baseDiffuseRoughness", + "metallic", + "ambientOcclusion", // not part of OpenPBR but supported via MaterialX container network + "specularWeight", + "specularColor", + "specularRoughness", + "specularIOR", + "specularRoughnessAnisotropy", + "transmissionWeight", + "transmissionColor", + "transmissionDepth", + "transmissionScatter", + "transmissionScatterAnisotropy", + "transmissionDispersionScale", + "transmissionDispersionAbbeNumber", + "subsurfaceWeight", + "subsurfaceColor", + "subsurfaceRadius", + "subsurfaceRadiusScale", + "subsurfaceScatterAnisotropy", + "coatWeight", + "coatColor", + "coatRoughness", + "coatRoughnessAnisotropy", + "coatIOR", + "coatDarkening", + "fuzzWeight", + "fuzzColor", + "fuzzRoughness", + "emissionLuminance", + "emissionColor", + // height* properties are not part of OpenPBR but are supported via MaterialX + // container network, used to model displacement + "height", + "heightLevel", + "heightScale", + "thinFilmWeight", + "thinFilmThickness", + "thinFilmIOR", + "opacity", + "normal", + "coatNormal", + "tangent", + "coatTangent", +}; // clang-format on const std::vector uniform_usages = { "IOR", @@ -95,10 +150,25 @@ const std::vector uniform_usages = { "IOR", const std::vector normal_usages = { "normal", "coatNormal" }; -const std::unordered_set color_usages = { "absorptionColor", "baseColor", - "coatColor", "emissive", - "scatteringColor", "scatteringDistanceScale", - "sheenColor", "specularEdgeColor" }; +const std::unordered_set color_usages = { + // ASM color usages + "absorptionColor", + "baseColor", + "coatColor", + "emissive", + "scatteringColor", + "scatteringDistanceScale", + "sheenColor", + "specularEdgeColor", + // OpenPBR-native color usages + "specularColor", + "transmissionColor", + "transmissionScatter", + "subsurfaceColor", + "subsurfaceRadiusScale", + "fuzzColor", + "emissionColor", +}; const std::map reserved_label_map = { { "$time", "Time" }, { "$outputsize", "Output Size" }, @@ -214,7 +284,87 @@ const std::map default_channels = { { "heightScale", { SdfValueTypeNames->Float, VtValue(1.0f), { VtValue(0.0f), VtValue(1000.0f) } } }, { "normalScale", - { SdfValueTypeNames->Float, VtValue(1.0f), { VtValue(0.0f), VtValue(1000.0f) } } } + { SdfValueTypeNames->Float, VtValue(1.0f), { VtValue(0.0f), VtValue(1000.0f) } } }, + + // OpenPBR defaults + { "baseWeight", { SdfValueTypeNames->Float, VtValue(1.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "baseDiffuseRoughness", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "specularWeight", + { SdfValueTypeNames->Float, VtValue(1.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "specularColor", + { SdfValueTypeNames->Color3f, + VtValue(GfVec3f(1.0f, 1.0f, 1.0f)), + { VtValue(GfVec3f(0.0f, 0.0f, 0.0f)), VtValue(GfVec3f(1.0f, 1.0f, 1.0f)) } } }, + { "specularRoughness", + { SdfValueTypeNames->Float, VtValue(0.3f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "specularRoughnessAnisotropy", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "specularIOR", + { SdfValueTypeNames->Float, VtValue(1.5f), { VtValue(1.0f), VtValue(3.0f) } } }, + { "transmissionWeight", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "transmissionColor", + { SdfValueTypeNames->Color3f, + VtValue(GfVec3f(1.0f, 1.0f, 1.0f)), + { VtValue(GfVec3f(0.0f, 0.0f, 0.0f)), VtValue(GfVec3f(1.0f, 1.0f, 1.0f)) } } }, + { "transmissionDepth", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "transmissionScatter", + { SdfValueTypeNames->Color3f, + VtValue(GfVec3f(1.0f, 1.0f, 1.0f)), + { VtValue(GfVec3f(0.0f, 0.0f, 0.0f)), VtValue(GfVec3f(1.0f, 1.0f, 1.0f)) } } }, + { "transmissionScatterAnisotropy", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(-1.0f), VtValue(1.0f) } } }, + { "transmissionDispersionScale", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "transmissionDispersionAbbeNumber", + { SdfValueTypeNames->Float, VtValue(20.0f), { VtValue(9.0f), VtValue(91.0f) } } }, + { "subsurfaceWeight", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "subsurfaceColor", + { SdfValueTypeNames->Color3f, + VtValue(GfVec3f(0.8f, 0.8f, 0.8f)), + { VtValue(GfVec3f(0.0f, 0.0f, 0.0f)), VtValue(GfVec3f(1.0f, 1.0f, 1.0f)) } } }, + { "subsurfaceRadius", + { SdfValueTypeNames->Float, VtValue(1.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "subsurfaceRadiusScale", + { SdfValueTypeNames->Color3f, + VtValue(GfVec3f(1.0f, 0.5f, 0.25f)), + { VtValue(GfVec3f(0.0f, 0.0f, 0.0f)), VtValue(GfVec3f(1.0f, 1.0f, 1.0f)) } } }, + { "subsurfaceScatterAnisotropy", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "coatWeight", { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "coatColor", + { SdfValueTypeNames->Color3f, + VtValue(GfVec3f(1.0f, 1.0f, 1.0f)), + { VtValue(GfVec3f(0.0f, 0.0f, 0.0f)), VtValue(GfVec3f(1.0f, 1.0f, 1.0f)) } } }, + { "coatRoughness", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "coatRoughnessAnisotropy", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(1.0f), VtValue(3.0f) } } }, + { "coatDarkening", + { SdfValueTypeNames->Float, VtValue(1.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "fuzzWeight", { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "fuzzColor", + { SdfValueTypeNames->Color3f, + VtValue(GfVec3f(0.8f, 0.8f, 0.8f)), + { VtValue(GfVec3f(0.0f, 0.0f, 0.0f)), VtValue(GfVec3f(1.0f, 1.0f, 1.0f)) } } }, + { "fuzzRoughness", + { SdfValueTypeNames->Float, VtValue(0.5f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "emissionWeight", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "emissionLuminance", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "emissionColor", + { SdfValueTypeNames->Color3f, + VtValue(GfVec3f(1.0f, 1.0f, 1.0f)), + { VtValue(GfVec3f(0.0f, 0.0f, 0.0f)), VtValue(GfVec3f(1.0f, 1.0f, 1.0f)) } } }, + { "thinFilmWeight", + { SdfValueTypeNames->Float, VtValue(0.0f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "thinFilmThickness", + { SdfValueTypeNames->Float, VtValue(0.5f), { VtValue(0.0f), VtValue(1.0f) } } }, + { "thinFilmIOR", { SdfValueTypeNames->Float, VtValue(1.4f), { VtValue(1.0f), VtValue(3.0f) } } } }; const std::vector default_resolutions = { 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 }; @@ -242,6 +392,10 @@ getResolutionVariantName(size_t xResLog2, size_t yResLog2) GraphType guessGraphType(const SubstanceAir::GraphDesc& graphDesc) { + if (graphDesc.mMaterialModel == SubstanceAir::GraphMaterialModel_OpenPBR_v1_1) { + return GraphType::Material; + } + // If we have an explicit graph type we use it if (graphDesc.mType == SubstanceAir::GraphType::GraphType_Material || graphDesc.mType == SubstanceAir::GraphType::GraphType_DecalMaterial || @@ -260,6 +414,7 @@ guessGraphType(const SubstanceAir::GraphDesc& graphDesc) return GraphType::Material; } } + // Check for light if (hasUsage("environment", graphDesc) || hasUsage("panorama", graphDesc)) { return GraphType::Light; @@ -268,6 +423,12 @@ guessGraphType(const SubstanceAir::GraphDesc& graphDesc) return GraphType::Unknown; } +bool +isOpenPbrNativeGraph(const SubstanceAir::GraphDesc& graphDesc) +{ + return graphDesc.mMaterialModel == SubstanceAir::GraphMaterialModel_OpenPBR_v1_1; +} + std::string graphTypeToString(GraphType type) { @@ -342,6 +503,35 @@ hasUsage(const std::string& usage, const GraphDesc& graphDesc) return false; } +bool +hasImageUsage(const std::string& usage, const GraphDesc& graphDesc) +{ + // This should potentially be cached to avoid double loop + for (const auto& o : graphDesc.mOutputs) { + for (const auto& c : o.mChannelsStr) { + if (usage == c.c_str()) { + return o.mType == Substance_IOType_Image; + } + } + } + return false; +} + +std::pair +getUsageAndSubstanceType(const std::string& usage, const GraphDesc& graphDesc) +{ + // This should potentially be cached to avoid double loop + for (const auto& o : graphDesc.mOutputs) { + for (const auto& c : o.mChannelsStr) { + if (usage == c.c_str()) { + return { true, o.mType }; + } + } + } + // The iotype doesn't matter since the first value is false + return { false, SubstanceIOType::Substance_IOType_Float }; +} + bool hasInput(const std::string& identifier, const GraphDesc& graphDesc) { diff --git a/sbsar/src/usdGeneration/usdGenerationHelpers.h b/sbsar/src/usdGeneration/usdGenerationHelpers.h index 8fe3a3f3..8bf9d4af 100644 --- a/sbsar/src/usdGeneration/usdGenerationHelpers.h +++ b/sbsar/src/usdGeneration/usdGenerationHelpers.h @@ -15,6 +15,7 @@ governing permissions and limitations under the License. #include #include +#include #include #include @@ -37,9 +38,12 @@ struct DefaultChannel std::pair range; ///< Valid range (min,max) for the channel }; -/// List of SBSAR channel usages that have a known mapping +/// List of SBSAR channel usages that have a known mapping (ASM material model) extern const std::vector mapped_usages; +/// List of SBSAR channel usages for graphs authored with the OpenPBR material model +extern const std::vector mapped_usages_openpbr; + /// List of SBSAR channel usages that should use uniform values extern const std::vector uniform_usages; @@ -102,6 +106,13 @@ enum class GraphType GraphType guessGraphType(const SubstanceAir::GraphDesc& graphDesc); +/// @brief Detect whether a graph was authored with the OpenPBR material model. +/// +/// @param graphDesc Description of the SBSAR graph to examine +/// @return True if the graph uses the OpenPBR material model +bool +isOpenPbrNativeGraph(const SubstanceAir::GraphDesc& graphDesc); + /// @brief Convert GraphType enum to string representation. /// @param type GraphType to convert /// @return String representation ("material", "light/environment", or "unknown") @@ -134,6 +145,23 @@ getGraphName(const SubstanceAir::GraphDesc& desc); bool hasUsage(const std::string& usage, const SubstanceAir::GraphDesc& graphDesc); +/// @brief Check if a graph has an output channel with the specified usage name and the output is of +/// image type (texture). +/// @param usage Output usage to check for +/// @param graphDesc Graph description to search in +/// @return True if the graph has the specified usage output and it is of image type +bool +hasImageUsage(const std::string& usage, const SubstanceAir::GraphDesc& graphDesc); + +/// @brief Check if a graph has an output channel with the specified usage name, return true and +/// output type, otherwise return false +/// @param usage Output usage to check for +/// @param graphDesc Graph description to search in +/// @return Pair where first element is true if the graph has the specified usage output, and second +/// element is the output type +std::pair +getUsageAndSubstanceType(const std::string& usage, const SubstanceAir::GraphDesc& graphDesc); + /// @brief Check if a graph has an input parameter with the specified identifier. /// @param identifier Input parameter identifier to check for /// @param graphDesc Graph description to search in diff --git a/sbsar/test/sanityTests.cpp b/sbsar/test/sanityTests.cpp index f7b4847e..6e0b6e8b 100644 --- a/sbsar/test/sanityTests.cpp +++ b/sbsar/test/sanityTests.cpp @@ -14,6 +14,7 @@ governing permissions and limitations under the License. #include #include +// TODO:: This is doing something different than most other sanity tests. Why the inconsistency? TEST(SbsarSanityTests, HasSBSARFormat) { PXR_NAMESPACE_USING_DIRECTIVE diff --git a/sbsar/test/test_sbsarConfig.cpp b/sbsar/test/test_sbsarConfig.cpp index e7729fd4..88d2ae43 100644 --- a/sbsar/test/test_sbsarConfig.cpp +++ b/sbsar/test/test_sbsarConfig.cpp @@ -9,6 +9,7 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +#include "utils.h" #include #include diff --git a/serialization/CMakeLists.txt b/serialization/CMakeLists.txt new file mode 100644 index 00000000..ecc2f538 --- /dev/null +++ b/serialization/CMakeLists.txt @@ -0,0 +1,36 @@ +set(TARGET_NAME usdSerialization) +project(${TARGET_NAME}) + +if (NOT TARGET usd) + find_package(pxr REQUIRED) +endif() + +add_library(${TARGET_NAME} SHARED + "src/buffer.cpp" + "include/serialization/api.h" + "include/serialization/buffer.h" +) + +target_compile_definitions(${TARGET_NAME} PRIVATE SERIALIZATION_EXPORTS) + +target_include_directories(${TARGET_NAME} + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" +) + +target_link_libraries(${TARGET_NAME} + PUBLIC + arch +) + +set_target_properties(${TARGET_NAME} PROPERTIES + INSTALL_RPATH "${CMAKE_INSTALL_RPATH}" + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + +install(TARGETS ${TARGET_NAME}) + +if(USD_FILEFORMATS_BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/serialization/include/serialization/api.h b/serialization/include/serialization/api.h new file mode 100644 index 00000000..9a9abd9a --- /dev/null +++ b/serialization/include/serialization/api.h @@ -0,0 +1,36 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +// This library has no USD dependency besides prints. If needed, these macros can be manually +// written out here and the PXR include removed, if prints are removed as well + +#include "pxr/base/arch/export.h" + +#if defined(PXR_STATIC) +#define SERIALIZATION_API +#define SERIALIZATION_API_TEMPLATE_CLASS(...) +#define SERIALIZATION_API_TEMPLATE_STRUCT(...) +#define SERIALIZATION_LOCAL +#else +#if defined(SERIALIZATION_EXPORTS) +#define SERIALIZATION_API ARCH_EXPORT +#define SERIALIZATION_API_TEMPLATE_CLASS(...) ARCH_EXPORT_TEMPLATE(class, __VA_ARGS__) +#define SERIALIZATION_API_TEMPLATE_STRUCT(...) ARCH_EXPORT_TEMPLATE(struct, __VA_ARGS__) +#else +#define SERIALIZATION_API ARCH_IMPORT +#define SERIALIZATION_API_TEMPLATE_CLASS(...) ARCH_IMPORT_TEMPLATE(class, __VA_ARGS__) +#define SERIALIZATION_API_TEMPLATE_STRUCT(...) ARCH_IMPORT_TEMPLATE(struct, __VA_ARGS__) +#endif +#define SERIALIZATION_LOCAL ARCH_HIDDEN +#endif diff --git a/serialization/include/serialization/buffer.h b/serialization/include/serialization/buffer.h new file mode 100644 index 00000000..6c8d94ea --- /dev/null +++ b/serialization/include/serialization/buffer.h @@ -0,0 +1,96 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#pragma once + +#include + +#include +#include +#include +#include + +namespace adobe::usd::serialization { + +/** + * Binary buffer writer for serializing primitive types into a byte buffer. + * Data is written in native byte order. + */ +class SERIALIZATION_API BufferWriter +{ +public: + /// Append a 32-bit unsigned integer in native byte order. + void WriteUint32(uint32_t value); + /// Append a 64-bit unsigned integer in native byte order. + void WriteUint64(uint64_t value); + /// Append a size_t value encoded as uint64_t for cross-platform consistency. + void WriteSizeT(size_t value); + /// Append a string: length prefix (via WriteSizeT) followed by the UTF-8 bytes. + void WriteString(const std::string& str); + /// Append a string-to-string map: entry count followed by key/value string pairs. + void WriteMap(const std::map& map); + /// Append raw bytes. No-op if data is null or size is zero. + void WriteBytes(const void* data, size_t size); + + /// Return the serialized byte buffer. After calling this, the writer is reset. + std::vector Finish(); + + /// Return the current size of the buffer without finishing. + size_t Size() const { return _buffer.size(); } + +private: + std::vector _buffer; + + // Append raw bytes to _buffer without any length prefix. + void AppendRaw(const void* data, size_t size); +}; + +/** + * Binary buffer reader for deserializing primitive types from a byte buffer. + * Reads must match the order and types used during writing. If any read exceeds the buffer, + * the reader enters an error state (HasError() returns true) and subsequent reads return + * zero/empty values. + */ +class SERIALIZATION_API BufferReader +{ +public: + explicit BufferReader(const std::vector& data); + BufferReader(const uint8_t* data, size_t size); + + /// Read a 32-bit unsigned integer. Sets the error state if insufficient bytes remain. + uint32_t ReadUint32(); + /// Read a 64-bit unsigned integer. Sets the error state if insufficient bytes remain. + uint64_t ReadUint64(); + /// Read a size_t value encoded as uint64_t. Sets the error state on underflow. + size_t ReadSizeT(); + /// Read a length-prefixed string. Returns empty and sets the error state on underflow. + std::string ReadString(); + /// Read a string-to-string map written by WriteMap. Sets the error state on underflow. + std::map ReadMap(); + /// Copy @p size bytes into @p buffer. Returns false and sets error state on underflow. + bool ReadBytes(void* buffer, size_t size); + + bool HasError() const { return _error; } + size_t BytesRead() const { return _offset; } + size_t BytesRemaining() const { return _error ? 0 : _size - _offset; } + +private: + const uint8_t* _data; + size_t _size; + size_t _offset = 0; + bool _error = false; + + // Consume size bytes from _data into dest. Sets _error and returns false on underflow. + bool ReadRaw(void* dest, size_t size); +}; + +} // namespace adobe::usd::serialization diff --git a/serialization/src/buffer.cpp b/serialization/src/buffer.cpp new file mode 100644 index 00000000..d34f8a0c --- /dev/null +++ b/serialization/src/buffer.cpp @@ -0,0 +1,169 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +namespace adobe::usd::serialization { + +// -- BufferWriter -- + +void +BufferWriter::AppendRaw(const void* data, size_t size) +{ + const auto* bytes = static_cast(data); + _buffer.insert(_buffer.end(), bytes, bytes + size); +} + +void +BufferWriter::WriteUint32(uint32_t value) +{ + AppendRaw(&value, sizeof(value)); +} + +void +BufferWriter::WriteUint64(uint64_t value) +{ + AppendRaw(&value, sizeof(value)); +} + +void +BufferWriter::WriteSizeT(size_t value) +{ + // Always serialize as uint64_t for cross-platform consistency + uint64_t v = static_cast(value); + AppendRaw(&v, sizeof(v)); +} + +void +BufferWriter::WriteString(const std::string& str) +{ + WriteSizeT(str.size()); + if (!str.empty()) { + AppendRaw(str.data(), str.size()); + } +} + +void +BufferWriter::WriteMap(const std::map& map) +{ + WriteSizeT(map.size()); + for (const auto& [key, value] : map) { + WriteString(key); + WriteString(value); + } +} + +void +BufferWriter::WriteBytes(const void* data, size_t size) +{ + if (data != nullptr && size > 0) { + AppendRaw(data, size); + } +} + +std::vector +BufferWriter::Finish() +{ + return std::move(_buffer); +} + +// -- BufferReader -- + +BufferReader::BufferReader(const std::vector& data) + : _data(data.data()) + , _size(data.size()) +{} + +BufferReader::BufferReader(const uint8_t* data, size_t size) + : _data(data) + , _size(size) +{} + +bool +BufferReader::ReadRaw(void* dest, size_t size) +{ + // Overflow-safe: _offset <= _size is an invariant, so compare against the + // remaining space rather than computing _offset + size (which can wrap when + // `size` is a length prefix near SIZE_MAX). + if (_error || size > _size - _offset) { + _error = true; + return false; + } + memcpy(dest, _data + _offset, size); + _offset += size; + return true; +} + +uint32_t +BufferReader::ReadUint32() +{ + uint32_t value = 0; + ReadRaw(&value, sizeof(value)); + return value; +} + +uint64_t +BufferReader::ReadUint64() +{ + uint64_t value = 0; + ReadRaw(&value, sizeof(value)); + return value; +} + +size_t +BufferReader::ReadSizeT() +{ + uint64_t value = 0; + ReadRaw(&value, sizeof(value)); + return static_cast(value); +} + +std::string +BufferReader::ReadString() +{ + size_t len = ReadSizeT(); + if (_error || len == 0) { + return {}; + } + if (len > _size - _offset) { // overflow-safe; see ReadRaw + _error = true; + return {}; + } + std::string result(reinterpret_cast(_data + _offset), len); + _offset += len; + return result; +} + +std::map +BufferReader::ReadMap() +{ + std::map result; + size_t count = ReadSizeT(); + for (size_t i = 0; i < count && !_error; ++i) { + std::string key = ReadString(); + std::string value = ReadString(); + if (!_error) { + result[std::move(key)] = std::move(value); + } + } + return result; +} + +bool +BufferReader::ReadBytes(void* buffer, size_t size) +{ + return ReadRaw(buffer, size); +} + +} // namespace adobe::usd::serialization diff --git a/serialization/tests/CMakeLists.txt b/serialization/tests/CMakeLists.txt new file mode 100644 index 00000000..282a4a29 --- /dev/null +++ b/serialization/tests/CMakeLists.txt @@ -0,0 +1,21 @@ +find_package(GTest REQUIRED) +include(GoogleTest) + +set(TARGET_NAME serializationLibTests) + +add_executable(${TARGET_NAME} + testBuffer.cpp +) + +target_link_libraries(${TARGET_NAME} PRIVATE + GTest::gtest + GTest::gtest_main + usdSerialization +) + +set_target_properties(${TARGET_NAME} PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + +add_test(NAME ${TARGET_NAME} COMMAND ${TARGET_NAME}) diff --git a/serialization/tests/testBuffer.cpp b/serialization/tests/testBuffer.cpp new file mode 100644 index 00000000..e3c36d76 --- /dev/null +++ b/serialization/tests/testBuffer.cpp @@ -0,0 +1,279 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include + +#include + +using namespace adobe::usd::serialization; + +TEST(BufferTests, Uint32RoundTrip) +{ + BufferWriter writer; + writer.WriteUint32(0); + writer.WriteUint32(42); + writer.WriteUint32(UINT32_MAX); + auto data = writer.Finish(); + + BufferReader reader(data); + EXPECT_EQ(reader.ReadUint32(), 0u); + EXPECT_EQ(reader.ReadUint32(), 42u); + EXPECT_EQ(reader.ReadUint32(), UINT32_MAX); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(reader.BytesRemaining(), 0u); +} + +TEST(BufferTests, Uint64RoundTrip) +{ + BufferWriter writer; + writer.WriteUint64(0); + writer.WriteUint64(UINT64_MAX); + writer.WriteUint64(123456789012345ULL); + auto data = writer.Finish(); + + BufferReader reader(data); + EXPECT_EQ(reader.ReadUint64(), 0u); + EXPECT_EQ(reader.ReadUint64(), UINT64_MAX); + EXPECT_EQ(reader.ReadUint64(), 123456789012345ULL); + EXPECT_FALSE(reader.HasError()); +} + +TEST(BufferTests, SizeTRoundTrip) +{ + BufferWriter writer; + writer.WriteSizeT(0); + writer.WriteSizeT(999999); + auto data = writer.Finish(); + + // size_t is always serialized as uint64_t + EXPECT_EQ(data.size(), 2 * sizeof(uint64_t)); + + BufferReader reader(data); + EXPECT_EQ(reader.ReadSizeT(), 0u); + EXPECT_EQ(reader.ReadSizeT(), 999999u); + EXPECT_FALSE(reader.HasError()); +} + +TEST(BufferTests, StringRoundTrip) +{ + BufferWriter writer; + writer.WriteString("hello"); + writer.WriteString(""); + writer.WriteString("world"); + auto data = writer.Finish(); + + BufferReader reader(data); + EXPECT_EQ(reader.ReadString(), "hello"); + EXPECT_EQ(reader.ReadString(), ""); + EXPECT_EQ(reader.ReadString(), "world"); + EXPECT_FALSE(reader.HasError()); +} + +TEST(BufferTests, StringWithSpecialChars) +{ + BufferWriter writer; + writer.WriteString("line1\nline2"); + writer.WriteString("key=value"); + writer.WriteString("path/to/file.usd"); + auto data = writer.Finish(); + + BufferReader reader(data); + EXPECT_EQ(reader.ReadString(), "line1\nline2"); + EXPECT_EQ(reader.ReadString(), "key=value"); + EXPECT_EQ(reader.ReadString(), "path/to/file.usd"); + EXPECT_FALSE(reader.HasError()); +} + +TEST(BufferTests, MapRoundTrip) +{ + std::map original = { + { "alpha", "one" }, + { "beta", "two" }, + { "gamma", "three" }, + }; + + BufferWriter writer; + writer.WriteMap(original); + auto data = writer.Finish(); + + BufferReader reader(data); + auto decoded = reader.ReadMap(); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(decoded, original); +} + +TEST(BufferTests, EmptyMap) +{ + std::map empty; + + BufferWriter writer; + writer.WriteMap(empty); + auto data = writer.Finish(); + + BufferReader reader(data); + auto decoded = reader.ReadMap(); + EXPECT_FALSE(reader.HasError()); + EXPECT_TRUE(decoded.empty()); +} + +TEST(BufferTests, BytesRoundTrip) +{ + std::vector original = { 0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD }; + + BufferWriter writer; + writer.WriteSizeT(original.size()); + writer.WriteBytes(original.data(), original.size()); + auto data = writer.Finish(); + + BufferReader reader(data); + size_t len = reader.ReadSizeT(); + EXPECT_EQ(len, original.size()); + + std::vector decoded(len); + EXPECT_TRUE(reader.ReadBytes(decoded.data(), len)); + EXPECT_EQ(decoded, original); + EXPECT_FALSE(reader.HasError()); +} + +TEST(BufferTests, MixedTypes) +{ + BufferWriter writer; + writer.WriteUint32(1); + writer.WriteString("mixed"); + writer.WriteSizeT(100); + writer.WriteUint64(999); + writer.WriteMap({ { "x", "y" } }); + auto data = writer.Finish(); + + BufferReader reader(data); + EXPECT_EQ(reader.ReadUint32(), 1u); + EXPECT_EQ(reader.ReadString(), "mixed"); + EXPECT_EQ(reader.ReadSizeT(), 100u); + EXPECT_EQ(reader.ReadUint64(), 999u); + auto map = reader.ReadMap(); + EXPECT_EQ(map.size(), 1u); + EXPECT_EQ(map["x"], "y"); + EXPECT_FALSE(reader.HasError()); +} + +TEST(BufferTests, ReadPastEndSetsError) +{ + BufferWriter writer; + writer.WriteUint32(42); + auto data = writer.Finish(); + + BufferReader reader(data); + EXPECT_EQ(reader.ReadUint32(), 42u); + EXPECT_FALSE(reader.HasError()); + + // Now there's no data left + uint32_t val = reader.ReadUint32(); + EXPECT_EQ(val, 0u); + EXPECT_TRUE(reader.HasError()); +} + +TEST(BufferTests, ErrorStatePersists) +{ + std::vector data = { 0x01 }; // Only 1 byte + BufferReader reader(data); + + // Trying to read 4 bytes should fail + reader.ReadUint32(); + EXPECT_TRUE(reader.HasError()); + + // Subsequent reads should also fail + reader.ReadString(); + EXPECT_TRUE(reader.HasError()); + EXPECT_EQ(reader.BytesRemaining(), 0u); +} + +TEST(BufferTests, EmptyBuffer) +{ + std::vector empty; + BufferReader reader(empty); + EXPECT_EQ(reader.BytesRemaining(), 0u); + + reader.ReadUint32(); + EXPECT_TRUE(reader.HasError()); +} + +TEST(BufferTests, FinishResetsWriter) +{ + BufferWriter writer; + writer.WriteUint32(1); + auto data1 = writer.Finish(); + EXPECT_EQ(writer.Size(), 0u); + + writer.WriteUint32(2); + auto data2 = writer.Finish(); + + BufferReader reader1(data1); + EXPECT_EQ(reader1.ReadUint32(), 1u); + + BufferReader reader2(data2); + EXPECT_EQ(reader2.ReadUint32(), 2u); +} + +TEST(BufferTests, LargeMap) +{ + std::map large; + for (int i = 0; i < 100; ++i) { + large["key_" + std::to_string(i)] = "value_" + std::to_string(i * 10); + } + + BufferWriter writer; + writer.WriteMap(large); + auto data = writer.Finish(); + + BufferReader reader(data); + auto decoded = reader.ReadMap(); + EXPECT_FALSE(reader.HasError()); + EXPECT_EQ(decoded, large); +} + +TEST(BufferTests, OversizedStringLengthDoesNotOverread) +{ + // A hostile message: a size_t length prefix of UINT64_MAX, then no payload. + BufferWriter writer; + writer.WriteUint64(UINT64_MAX); // becomes the string's length prefix + auto data = writer.Finish(); + + BufferReader reader(data); + std::string result = reader.ReadString(); + EXPECT_TRUE(reader.HasError()); + EXPECT_TRUE(result.empty()); +} + +TEST(BufferTests, OversizedReadBytesLengthDoesNotOverread) +{ + std::vector data = { 0x01, 0x02, 0x03, 0x04 }; // 4 bytes available + BufferReader reader(data); + + std::vector dest(8, 0); + // Request a near-SIZE_MAX read; the overflow-prone check must reject it. + bool ok = reader.ReadBytes(dest.data(), SIZE_MAX - 1); + EXPECT_FALSE(ok); + EXPECT_TRUE(reader.HasError()); +} + +TEST(BufferTests, MapWithHugeCountTerminatesOnError) +{ + // count = UINT64_MAX, but no entries follow: must error out, not spin or overread. + BufferWriter writer; + writer.WriteUint64(UINT64_MAX); + auto data = writer.Finish(); + + BufferReader reader(data); + auto decoded = reader.ReadMap(); + EXPECT_TRUE(reader.HasError()); + EXPECT_TRUE(decoded.empty()); +} diff --git a/spz/CMakeLists.txt b/spz/CMakeLists.txt index 657b82f4..e784ca7e 100644 --- a/spz/CMakeLists.txt +++ b/spz/CMakeLists.txt @@ -1,6 +1,6 @@ option(NO_UNDEFINED "Active no-undefined compile options" ON) option(USD_FILEFORMATS_ENABLE_ASSET_TESTS "Build the more in depth unit tests using downloaded assets." OFF) -option(USDSPZ_ENABLE_INSTALL "Enable installation of plugin artifacts" ON) +cmake_dependent_option(USDSPZ_ENABLE_INSTALL "Enable installation of plugin artifacts" ON "USD_FILEFORMATS_ENABLE_INSTALL" OFF) if (NOT TARGET usd) find_package(pxr REQUIRED) @@ -13,6 +13,10 @@ find_package(SphericalHarmonics REQUIRED) add_subdirectory(src) + +# Pass this list from the src/CMakeLists.txt to the parent scope +set(SPZ_EXT_LIST ${SPZ_EXT_LIST} PARENT_SCOPE) + if (USD_FILEFORMATS_BUILD_TESTS) add_subdirectory(tests) endif () @@ -21,3 +25,5 @@ endif () set(CPACK_INSTALL_CMAKE_PROJECTS "src;usdSpz;ALL;/") include(CPack) + +fileformats_register_plugin("usdSpz") diff --git a/spz/src/CMakeLists.txt b/spz/src/CMakeLists.txt index f8c69977..5b674906 100644 --- a/spz/src/CMakeLists.txt +++ b/spz/src/CMakeLists.txt @@ -1,5 +1,7 @@ add_library(usdSpz SHARED) +set(SPZ_EXT_LIST "spz" PARENT_SCOPE) + usd_plugin_compile_config(usdSpz) target_compile_definitions(usdSpz PRIVATE USDSPZ_EXPORTS) @@ -40,29 +42,39 @@ PRIVATE # Allow an option for deferring the path replacement to install time if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) - set(PLUG_INFO_LIBRARY_PATH "\$\{PLUG_INFO_LIBRARY_PATH\}") + # We still need to go through `configure_file` even with `USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT` because we burn additional CMake variable beyond PLUG_INFO_LIBRARY_PATH + # So we set `PLUG_INFO_LIBRARY_PATH` as a no op value and let other CMake variables being burnt in + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") else() set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}usdSpz${CMAKE_SHARED_LIBRARY_SUFFIX}") endif() -configure_file(plugInfo.json.in plugInfo.json) -set_target_properties(usdSpz PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) -set_target_properties(usdSpz PROPERTIES RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") +configure_file(plugInfo.json.in plugInfo.json) +set_property(TARGET usdSpz APPEND PROPERTY RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json") +set_property(TARGET usdSpz APPEND PROPERTY RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") # USDSPZ_DESTINATION is set in the parent scope by the add_usd_fileformat macro if(USDSPZ_ENABLE_INSTALL) + set_property(TARGET usdSpz + APPEND PROPERTY + INSTALL_RPATH "${plugin_install_rpath_root}/." + ) install( TARGETS usdSpz + EXPORT usd-fileformats-targets RUNTIME DESTINATION ${USDSPZ_DESTINATION} COMPONENT Runtime LIBRARY DESTINATION ${USDSPZ_DESTINATION} COMPONENT Runtime + ARCHIVE DESTINATION ${USDSPZ_DESTINATION} COMPONENT Runtime RESOURCE DESTINATION ${USDSPZ_DESTINATION}/usdSpz/resources COMPONENT Runtime ) - install( - FILES plugInfo.root.json - DESTINATION ${USDSPZ_DESTINATION} - RENAME plugInfo.json - COMPONENT Runtime - ) + if(USD_FILEFORMATS_ENABLE_INSTALL_PLUGINFO_ROOT) + install( + FILES plugInfo.root.json + DESTINATION ${USDSPZ_DESTINATION} + RENAME plugInfo.json + COMPONENT Runtime + ) + endif() endif() diff --git a/spz/src/fileFormat.cpp b/spz/src/fileFormat.cpp index 9309f944..1eb8e26b 100644 --- a/spz/src/fileFormat.cpp +++ b/spz/src/fileFormat.cpp @@ -111,6 +111,14 @@ UsdSpzFileFormat::Read(SdfLayer* layer, const std::string& resolvedPath, bool me importSpzOptions.importGsplatWithZup = data->gsplatsWithZup; importSpzOptions.importGsplatClippingBox = data->gsplatsClippingBox; spz::UnpackOptions unpackOptions; + // Target coordinate system for unpacking: RUB (X-right, Y-up, Z-backward, USD default) + // unless importGsplatWithZup is set, in which case RFU (X-right, Y-forward, Z-up, + // Blender convention) is used because some Adobe generative/capture tools follow this + // convention + // SPZ may embed SpzExtensionCoordinateSystemAdobe metadata. When the metadata is present, + // the loader will convert from that source system to the target system specified here. + unpackOptions.to = importSpzOptions.importGsplatWithZup ? spz::CoordinateSystem::RFU + : spz::CoordinateSystem::RUB; GaussianCloud gaussianCloud = loadSpz(resolvedPath, unpackOptions); GUARD(importSpz(importSpzOptions, gaussianCloud, usd), "Error translating SPZ to USD\n"); GUARD( diff --git a/spz/src/plugInfo.json.in b/spz/src/plugInfo.json.in index bbaf6129..27aa8640 100644 --- a/spz/src/plugInfo.json.in +++ b/spz/src/plugInfo.json.in @@ -27,7 +27,7 @@ } } }, - "LibraryPath": "${PLUG_INFO_LIBRARY_PATH}", + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", "Name": "usdSpz_plugin", "ResourcePath": "resources", "Root": "..", diff --git a/spz/src/spzExport.cpp b/spz/src/spzExport.cpp index 0fbbc2c6..5ecc9f32 100644 --- a/spz/src/spzExport.cpp +++ b/spz/src/spzExport.cpp @@ -214,7 +214,6 @@ exportSpz(const UsdData& usd, spz::GaussianCloud& gaussianCloud) for (size_t shColIndex = 0; shColIndex < 3; ++shColIndex) { const std::size_t spzSHIndex = shRowIndex * 3 + shColIndex; const std::size_t usdSHIndex = shColIndex * numNonZeroSHBands + shRowIndex; - const std::size_t spzShCoeffOffset = spzSHIndex * totalMesh.points.size(); for (size_t i = 0; i < totalMesh.points.size(); ++i) { gaussianCloud.sh[i * numGsplatsSHCoeffs + spzSHIndex] = diff --git a/spz/tests/CMakeLists.txt b/spz/tests/CMakeLists.txt index f09fe8c2..7eb3da84 100644 --- a/spz/tests/CMakeLists.txt +++ b/spz/tests/CMakeLists.txt @@ -9,6 +9,8 @@ PRIVATE usd GTest::gtest GTest::gtest_main + fileformatUtilsTest + gtestCommon ) gtest_add_tests(TARGET spzSanityTests AUTO) diff --git a/spz/tests/sanityTests.cpp b/spz/tests/sanityTests.cpp index b1b7daf5..853e71fc 100644 --- a/spz/tests/sanityTests.cpp +++ b/spz/tests/sanityTests.cpp @@ -9,6 +9,8 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +#include +#include #include #include #include @@ -21,7 +23,7 @@ TEST(SPZSanityTests, LoadCube) { PXR_NAMESPACE_USING_DIRECTIVE - // Load an FBX - UsdStageRefPtr stage = UsdStage::Open("SanitySplats.spz"); + // Load an .spz file + UsdStageRefPtr stage = openAssetStage(assetDir + "SanitySplats.spz"); ASSERT_TRUE(stage); } diff --git a/stl/CMakeLists.txt b/stl/CMakeLists.txt index bc5e97a0..941b9dde 100644 --- a/stl/CMakeLists.txt +++ b/stl/CMakeLists.txt @@ -1,6 +1,6 @@ option(NO_UNDEFINED "Active no-undefined compile options" ON) option(USD_FILEFORMATS_ENABLE_ASSET_TESTS "Build the more in depth unit tests using downloaded assets." OFF) -option(USDSTL_ENABLE_INSTALL "Enable installation of plugin artifacts" ON) +cmake_dependent_option(USDSTL_ENABLE_INSTALL "Enable installation of plugin artifacts" ON "USD_FILEFORMATS_ENABLE_INSTALL" OFF) if (NOT TARGET usd) @@ -12,6 +12,10 @@ endif() add_subdirectory(src) + +# Pass this list from the src/CMakeLists.txt to the parent scope +set(STL_EXT_LIST ${STL_EXT_LIST} PARENT_SCOPE) + if(USD_FILEFORMATS_BUILD_TESTS) add_subdirectory(tests) endif() @@ -20,3 +24,5 @@ endif() set(CPACK_INSTALL_CMAKE_PROJECTS "src;usdStl;ALL;/") include(CPack) + +fileformats_register_plugin("usdStl") diff --git a/stl/src/CMakeLists.txt b/stl/src/CMakeLists.txt index 38f603de..ce5d5ff2 100644 --- a/stl/src/CMakeLists.txt +++ b/stl/src/CMakeLists.txt @@ -1,5 +1,7 @@ add_library(usdStl SHARED) +set(STL_EXT_LIST "stl" PARENT_SCOPE) + usd_plugin_compile_config(usdStl) target_compile_definitions(usdStl PRIVATE USDSTL_EXPORTS) @@ -43,29 +45,39 @@ PRIVATE # Allow an option for deferring the path replacement to install time if(USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT) - set(PLUG_INFO_LIBRARY_PATH "\$\{PLUG_INFO_LIBRARY_PATH\}") + # We still need to go through `configure_file` even with `USD_PLUGIN_DEFER_LIBRARY_PATH_REPLACEMENT` because we burn additional CMake variable beyond PLUG_INFO_LIBRARY_PATH + # So we set `PLUG_INFO_LIBRARY_PATH` as a no op value and let other CMake variables being burnt in + set(PLUG_INFO_LIBRARY_PATH "@PLUG_INFO_LIBRARY_PATH@") else() set(PLUG_INFO_LIBRARY_PATH "../${CMAKE_SHARED_LIBRARY_PREFIX}usdStl${CMAKE_SHARED_LIBRARY_SUFFIX}") endif() -configure_file(plugInfo.json.in plugInfo.json) -set_target_properties(usdStl PROPERTIES RESOURCE ${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json) -set_target_properties(usdStl PROPERTIES RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") +configure_file(plugInfo.json.in plugInfo.json) +set_property(TARGET usdStl APPEND PROPERTY RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json") +set_property(TARGET usdStl APPEND PROPERTY RESOURCE_FILES "${CMAKE_CURRENT_BINARY_DIR}/plugInfo.json:plugInfo.json") # USDSTL_DESTINATION is set in the parent scope by the add_usd_fileformat macro if(USDSTL_ENABLE_INSTALL) + set_property(TARGET usdStl + APPEND PROPERTY + INSTALL_RPATH "${plugin_install_rpath_root}/." + ) install( TARGETS usdStl + EXPORT usd-fileformats-targets RUNTIME DESTINATION ${USDSTL_DESTINATION} COMPONENT Runtime LIBRARY DESTINATION ${USDSTL_DESTINATION} COMPONENT Runtime + ARCHIVE DESTINATION ${USDSTL_DESTINATION} COMPONENT Runtime RESOURCE DESTINATION ${USDSTL_DESTINATION}/usdStl/resources COMPONENT Runtime ) - install( - FILES plugInfo.root.json - DESTINATION ${USDSTL_DESTINATION} - RENAME plugInfo.json - COMPONENT Runtime - ) + if(USD_FILEFORMATS_ENABLE_INSTALL_PLUGINFO_ROOT) + install( + FILES plugInfo.root.json + DESTINATION ${USDSTL_DESTINATION} + RENAME plugInfo.json + COMPONENT Runtime + ) + endif() endif() diff --git a/stl/src/fileFormat.cpp b/stl/src/fileFormat.cpp index 6549a1c3..fd11e6a8 100644 --- a/stl/src/fileFormat.cpp +++ b/stl/src/fileFormat.cpp @@ -20,8 +20,14 @@ governing permissions and limitations under the License. #include #include +#include #include +#include +#include +#include +#include + PXR_NAMESPACE_OPEN_SCOPE using namespace usdStl; @@ -44,11 +50,76 @@ UsdStlFileFormat::UsdStlFileFormat() UsdStlFileFormat::~UsdStlFileFormat() {} +namespace { + +// STL binary layout: 80-byte header, uint32 triangle count, then 50 bytes per triangle. +constexpr uint64_t STL_BINARY_HEADER_SIZE = 80; +constexpr uint64_t STL_BINARY_TRIANGLE_SIZE = 50; +constexpr uint64_t STL_BINARY_MIN_SIZE = STL_BINARY_HEADER_SIZE + 4; + +bool +looksLikeAsciiStl(const char* head, std::streamsize n) +{ + return n > 5 && std::string_view(head, 5) == "solid" && + std::isspace(static_cast(head[5])); +} + +bool +looksLikeBinaryStl(std::ifstream& infile, uint64_t fileSize) +{ + // Binary STL: file size must equal 84 + 50 * triangleCount exactly. + if (fileSize < STL_BINARY_MIN_SIZE) { + return false; + } + infile.seekg(static_cast(STL_BINARY_HEADER_SIZE), std::ios::beg); + uint32_t triangleCount = 0; + if (!infile.read(reinterpret_cast(&triangleCount), sizeof(triangleCount))) { + return false; + } + const uint64_t remaining = fileSize - STL_BINARY_MIN_SIZE; + return triangleCount == remaining / STL_BINARY_TRIANGLE_SIZE && + remaining % STL_BINARY_TRIANGLE_SIZE == 0; +} + +} // namespace + bool UsdStlFileFormat::CanRead(const std::string& filePath) const { - // Could check to see if it looks like valid stl data... - return true; + // Reject files that share the .stl extension but are not 3D-model STL, most notably EBU + // Tech 3264 subtitle files, whose header bytes 3-10 spell "STLNN.MM" (e.g. "STL25.01"). + // Without this check, the binary path interprets ASCII metadata as a uint32 triangle count + // and tries to allocate billions of facets. + // + // The binary check has priority over the ASCII check to stay consistent with `isAsciiStl` + // in stlModel.cpp: some binary STLs carry a `solid`-prefixed 80-byte header, and we want + // both classification points to agree on which path runs. +#if defined(_WIN32) + std::ifstream infile(ArchWindowsUtf8ToUtf16(filePath), std::ios::in | std::ios::binary); +#else + std::ifstream infile(filePath, std::ios::in | std::ios::binary); +#endif + if (!infile.is_open()) { + return false; + } + + infile.seekg(0, std::ios::end); + const std::streamoff endPos = infile.tellg(); + if (endPos < 0) { + return false; + } + const uint64_t fileSize = static_cast(endPos); + + infile.clear(); + if (looksLikeBinaryStl(infile, fileSize)) { + return true; + } + + infile.clear(); + infile.seekg(0, std::ios::beg); + char head[6] = { 0 }; + infile.read(head, sizeof(head)); + return looksLikeAsciiStl(head, infile.gcount()); } bool diff --git a/stl/src/plugInfo.json.in b/stl/src/plugInfo.json.in index 80336d87..6b25cc6b 100644 --- a/stl/src/plugInfo.json.in +++ b/stl/src/plugInfo.json.in @@ -13,7 +13,7 @@ } } }, - "LibraryPath": "${PLUG_INFO_LIBRARY_PATH}", + "LibraryPath": "@PLUG_INFO_LIBRARY_PATH@", "Name": "usdStl_plugin", "ResourcePath": "resources", "Root": "..", diff --git a/stl/src/stlExport.cpp b/stl/src/stlExport.cpp index fa917603..cf2299e8 100644 --- a/stl/src/stlExport.cpp +++ b/stl/src/stlExport.cpp @@ -50,7 +50,7 @@ exportStl(const ExportStlOptions& options, const UsdData& usd, StlModel& stl) for (const Node& node : usd.nodes) { GfMatrix4d worldTransform = node.worldTransform * upAxisTransform; for (int meshIndex : node.staticMeshes) { - if (meshIndex < 0 || meshIndex >= usd.meshes.size()) { + if (meshIndex < 0 || static_cast(meshIndex) >= usd.meshes.size()) { TF_WARN("Invalid mesh index %d -- Skipping", meshIndex); continue; } diff --git a/stl/src/stlModel.cpp b/stl/src/stlModel.cpp index c85865d4..cd68b6bf 100644 --- a/stl/src/stlModel.cpp +++ b/stl/src/stlModel.cpp @@ -10,6 +10,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ #include "stlModel.h" +#include #include #include #include @@ -228,24 +229,52 @@ StlModel::Read(const std::string& filename) facets.push_back(facet); } } else { - // skip header + // The triangle count is read straight out of the file, so a malformed or non-STL file + // (e.g. an EBU Tech 3264 subtitle file with the same .stl extension) can claim hundreds + // of millions of facets. All size math runs in uint64_t and uses division rather than + // multiplication to avoid any chance of wrap-around on platforms with narrow streamoff. + constexpr uint64_t BINARY_TRIANGLE_SIZE = sizeof(float) * 12 + ATTRIBUTE_COUNT_SIZE; // 50 + constexpr uint64_t BINARY_MIN_SIZE = BINARY_HEADER_SIZE + 4; + + stlFile.seekg(0, std::ios::end); + const std::streamoff endPos = stlFile.tellg(); + if (endPos < 0) { + TF_WARN("STL: failed to determine binary file size"); + return; + } + const uint64_t fileSize = static_cast(endPos); stlFile.seekg(BINARY_HEADER_SIZE, std::ios::beg); - int facetCount = 0; - stlFile.read(reinterpret_cast(&facetCount), sizeof(int)); + uint32_t facetCount = 0; + if (!stlFile.read(reinterpret_cast(&facetCount), sizeof(facetCount))) { + TF_WARN("STL: failed to read binary triangle count"); + return; + } + + if (fileSize < BINARY_MIN_SIZE || + facetCount > (fileSize - BINARY_MIN_SIZE) / BINARY_TRIANGLE_SIZE) { + TF_WARN("STL: binary triangle count %u inconsistent with file size %llu; rejecting", + facetCount, + static_cast(fileSize)); + return; + } + + facets.reserve(facetCount); // buffer to hold attributes value char attributes[ATTRIBUTE_COUNT_SIZE]; - for (int i = 0; i < facetCount; i++) { + for (uint32_t i = 0; i < facetCount; i++) { StlFacet facet; - stlFile.read(reinterpret_cast(&facet.normal), sizeof(float) * 3); - stlFile.read(reinterpret_cast(&facet.vertices[0]), sizeof(float) * 3); - stlFile.read(reinterpret_cast(&facet.vertices[1]), sizeof(float) * 3); - stlFile.read(reinterpret_cast(&facet.vertices[2]), sizeof(float) * 3); - - // skip over attributes bytes - stlFile.read(reinterpret_cast(&attributes), ATTRIBUTE_COUNT_SIZE); + if (!stlFile.read(reinterpret_cast(&facet.normal), sizeof(float) * 3) || + !stlFile.read(reinterpret_cast(&facet.vertices[0]), sizeof(float) * 3) || + !stlFile.read(reinterpret_cast(&facet.vertices[1]), sizeof(float) * 3) || + !stlFile.read(reinterpret_cast(&facet.vertices[2]), sizeof(float) * 3) || + !stlFile.read(reinterpret_cast(&attributes), ATTRIBUTE_COUNT_SIZE)) { + TF_WARN("STL: truncated binary file at facet %u of %u; rejecting", i, facetCount); + facets.clear(); + return; + } facets.push_back(facet); } diff --git a/stl/tests/CMakeLists.txt b/stl/tests/CMakeLists.txt index 3d02e759..d7c55d55 100644 --- a/stl/tests/CMakeLists.txt +++ b/stl/tests/CMakeLists.txt @@ -9,6 +9,8 @@ PRIVATE usd GTest::gtest GTest::gtest_main + fileformatUtilsTest + gtestCommon ) gtest_add_tests(TARGET stlSanityTests AUTO) diff --git a/stl/tests/sanityTests.cpp b/stl/tests/sanityTests.cpp index ca1d85a8..c8c42600 100644 --- a/stl/tests/sanityTests.cpp +++ b/stl/tests/sanityTests.cpp @@ -9,6 +9,8 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +#include +#include #include #include #include @@ -20,6 +22,6 @@ governing permissions and limitations under the License. PXR_NAMESPACE_USING_DIRECTIVE TEST(STLSanityTests, LoadCube) { - UsdStageRefPtr stage = UsdStage::Open("SanityCube.stl"); + UsdStageRefPtr stage = openAssetStage(assetDir + "SanityCube.stl"); ASSERT_TRUE(stage); } diff --git a/test/baseline/Darwin/fbx/SimpleRobotwithUdims.jpg b/test/baseline/Darwin/fbx/SimpleRobotwithUdims.jpg index a9db21d4..b8265981 100644 Binary files a/test/baseline/Darwin/fbx/SimpleRobotwithUdims.jpg and b/test/baseline/Darwin/fbx/SimpleRobotwithUdims.jpg differ diff --git a/test/baseline/Darwin/fbx/cube-colors.jpg b/test/baseline/Darwin/fbx/cube-colors.jpg index 0c5cdebe..fc661954 100644 Binary files a/test/baseline/Darwin/fbx/cube-colors.jpg and b/test/baseline/Darwin/fbx/cube-colors.jpg differ diff --git a/test/baseline/Darwin/fbx/cube.jpg b/test/baseline/Darwin/fbx/cube.jpg index 5e419500..9a8e0fb6 100644 Binary files a/test/baseline/Darwin/fbx/cube.jpg and b/test/baseline/Darwin/fbx/cube.jpg differ diff --git a/test/baseline/Darwin/obj/nut_obj/nut.jpg b/test/baseline/Darwin/obj/nut_obj/nut.jpg index 0d3677d5..6c149dbc 100644 Binary files a/test/baseline/Darwin/obj/nut_obj/nut.jpg and b/test/baseline/Darwin/obj/nut_obj/nut.jpg differ diff --git a/test/baseline/Darwin/obj/stone/stone.jpg b/test/baseline/Darwin/obj/stone/stone.jpg index 3e2f7d84..0cb97401 100644 Binary files a/test/baseline/Darwin/obj/stone/stone.jpg and b/test/baseline/Darwin/obj/stone/stone.jpg differ diff --git a/test/baseline/Darwin/sbsar/cube.jpg b/test/baseline/Darwin/sbsar/cube.jpg index e20ace30..cd8cd98e 100644 Binary files a/test/baseline/Darwin/sbsar/cube.jpg and b/test/baseline/Darwin/sbsar/cube.jpg differ diff --git a/test/baseline/Linux/fbx/SimpleRobotwithUdims.jpg b/test/baseline/Linux/fbx/SimpleRobotwithUdims.jpg index 1b153f90..38298dec 100644 Binary files a/test/baseline/Linux/fbx/SimpleRobotwithUdims.jpg and b/test/baseline/Linux/fbx/SimpleRobotwithUdims.jpg differ diff --git a/test/baseline/Linux/fbx/cube-colors.jpg b/test/baseline/Linux/fbx/cube-colors.jpg index 9692f928..4651594f 100644 Binary files a/test/baseline/Linux/fbx/cube-colors.jpg and b/test/baseline/Linux/fbx/cube-colors.jpg differ diff --git a/test/baseline/Linux/fbx/cube.jpg b/test/baseline/Linux/fbx/cube.jpg index ae07415a..067b4e37 100644 Binary files a/test/baseline/Linux/fbx/cube.jpg and b/test/baseline/Linux/fbx/cube.jpg differ diff --git a/test/baseline/Linux/obj/nut_obj/nut.jpg b/test/baseline/Linux/obj/nut_obj/nut.jpg index abaf44af..10578de0 100644 Binary files a/test/baseline/Linux/obj/nut_obj/nut.jpg and b/test/baseline/Linux/obj/nut_obj/nut.jpg differ diff --git a/test/baseline/Linux/obj/stone/stone.jpg b/test/baseline/Linux/obj/stone/stone.jpg index 9b3860ba..c173acb8 100644 Binary files a/test/baseline/Linux/obj/stone/stone.jpg and b/test/baseline/Linux/obj/stone/stone.jpg differ diff --git a/test/baseline/Windows/fbx/SimpleRobotwithUdims.jpg b/test/baseline/Windows/fbx/SimpleRobotwithUdims.jpg index 1b153f90..38298dec 100644 Binary files a/test/baseline/Windows/fbx/SimpleRobotwithUdims.jpg and b/test/baseline/Windows/fbx/SimpleRobotwithUdims.jpg differ diff --git a/test/baseline/Windows/fbx/cube-colors.jpg b/test/baseline/Windows/fbx/cube-colors.jpg index 9692f928..4651594f 100644 Binary files a/test/baseline/Windows/fbx/cube-colors.jpg and b/test/baseline/Windows/fbx/cube-colors.jpg differ diff --git a/test/baseline/Windows/fbx/cube.jpg b/test/baseline/Windows/fbx/cube.jpg index ae07415a..067b4e37 100644 Binary files a/test/baseline/Windows/fbx/cube.jpg and b/test/baseline/Windows/fbx/cube.jpg differ diff --git a/test/baseline/Windows/obj/nut_obj/nut.jpg b/test/baseline/Windows/obj/nut_obj/nut.jpg index abaf44af..10578de0 100644 Binary files a/test/baseline/Windows/obj/nut_obj/nut.jpg and b/test/baseline/Windows/obj/nut_obj/nut.jpg differ diff --git a/test/baseline/Windows/obj/stone/stone.jpg b/test/baseline/Windows/obj/stone/stone.jpg index 9b3860ba..c173acb8 100644 Binary files a/test/baseline/Windows/obj/stone/stone.jpg and b/test/baseline/Windows/obj/stone/stone.jpg differ diff --git a/test/gtest_common/CMakeLists.txt b/test/gtest_common/CMakeLists.txt new file mode 100644 index 00000000..08454241 --- /dev/null +++ b/test/gtest_common/CMakeLists.txt @@ -0,0 +1,2 @@ +add_library(gtestCommon INTERFACE) +target_include_directories(gtestCommon INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/test/gtest_common/common_gtest_args.h b/test/gtest_common/common_gtest_args.h new file mode 100644 index 00000000..8907e950 --- /dev/null +++ b/test/gtest_common/common_gtest_args.h @@ -0,0 +1,61 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#pragma once + +#include +#include +#include + +// default when run directly from the command line and parameters aren't passed in +#ifdef TEST_ASSETS_DIR +inline std::string assetDir = TEST_ASSETS_DIR; +#else +inline std::string assetDir = "./"; +#endif + +#ifdef TEST_EXEC_DIR +inline std::string exeDir = TEST_EXEC_DIR; +#else +inline std::string exeDir = "./"; +#endif + +inline std::string +getOption(const std::vector& args, const std::string& option_name) +{ + for (auto it = args.begin(), end = args.end(); it != end; ++it) { + if (*it == option_name) + if (it + 1 != end) + return *(it + 1); + } + + return ""; +} + +inline void +parseArgs(int argc, char** argv) +{ + const std::vector args(argv + 1, argv + argc); + + std::string exeDirOption = getOption(args, "-e"); + if (exeDirOption != "") { + exeDir = exeDirOption; + } + + std::string assetDirOption = getOption(args, "-a"); + if (assetDirOption != "") { + std::filesystem::path assetDirPath = assetDirOption; + std::filesystem::path normalizedAssetDir = assetDirPath.lexically_normal(); + assetDir = normalizedAssetDir.generic_string(); + } +} + +#define TEST_CMAKE_ONLY(a, b) TEST_F(a, b) diff --git a/test/gtest_common/common_gtest_main.h b/test/gtest_common/common_gtest_main.h new file mode 100644 index 00000000..a4739b0f --- /dev/null +++ b/test/gtest_common/common_gtest_main.h @@ -0,0 +1,24 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#include +#include + +int +main(int argc, char** argv) +{ + setlocale(LC_NUMERIC, "C"); + ::testing::InitGoogleTest(&argc, argv); + + parseArgs(argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test.py b/test/test.py index 77dc2b73..0ca758f6 100644 --- a/test/test.py +++ b/test/test.py @@ -8,6 +8,7 @@ import pytest import subprocess import sys +import warnings import zipfile from pathlib import Path from pxr import Usd @@ -26,6 +27,66 @@ OUTPUT_FOLDERNAME = "output" CONVERTED_SUFFIX = "_roundtrip" +# --- Temporary OpenPBR-on-Metal workaround -------------------------------- +# MaterialX 'samplerCube' (mx_latlong_map_lookup) does not compile on Metal, so +# OpenPBR material output renders untextured on macOS and diverges from the +# UsdPreviewSurface baselines on every platform. Until we build against a USD +# release that supports MaterialX samplerCube on Metal (USD 26.05 or newer), +# force UsdPreviewSurface output for the affected assets. The render step sets +# the USD_FILEFORMATS_WRITE_OPENPBR=0 env var on the usdrecord subprocess (which +# also reaches sbsar materials generated from a referenced .sbsar, unlike a +# per-file SdfFileFormat arg on the root layer); the roundtrip convert step +# passes the per-file 'writeOpenPBR=false' arg (in-process, where the env var +# would be cached by TfEnvSetting). Remove this list and the associated plumbing +# once the USD dependency is updated. +OPENPBR_DISABLED_ASSETS = { + "fbx/cube-colors.fbx", + "fbx/cube.fbx", + "fbx/Megaphone_01_Lowpoly.fbx", + "fbx/SimpleRobotwithUdims.fbx", + "gltf/book f.glb", + "gltf/Emoticon_40.glb", + "gltf/VET.glb", + "obj/nut_obj/nut.obj", + "obj/stone/stone.obj", + "sbsar/cube.usd", + "sbsar/sphere.usd", +} + + +def is_openpbr_disabled(asset_path): + """Return True if OpenPBR output should be disabled for this asset.""" + try: + relative = os.path.relpath(asset_path, ASSET_PATH) + except ValueError: + return False + return relative.replace(os.sep, "/") in OPENPBR_DISABLED_ASSETS + + +def with_openpbr_disabled(asset_path): + """Append the per-file arg that forces UsdPreviewSurface (no OpenPBR).""" + return f"{asset_path}:SDF_FORMAT_ARGS:writeOpenPBR=false" + + +# --- Temporarily disabled roundtrip tests --------------------------------- +# Assets whose roundtrip conversion is disabled while a known plugin bug is +# fixed in the library. nut.obj: Phong->PBR translation aborts with 'Invalid +# diffuse image' on a material that has no diffuse texture, so the roundtrip +# export throws. The basic render still runs. Remove once the conversion fix +# lands. +ROUNDTRIP_DISABLED_ASSETS = { +} + + +def is_roundtrip_disabled(asset_path): + """Return True if the roundtrip test is temporarily disabled for this asset.""" + try: + relative = os.path.relpath(asset_path, ASSET_PATH) + except ValueError: + return False + return relative.replace(os.sep, "/") in ROUNDTRIP_DISABLED_ASSETS + + def compare_images_with_similarity_threshold(baseline_img_path, generated_img_path, similarity_threshold=0.9): """ Compare two images and check if they are similar based on a similarity threshold. @@ -66,16 +127,24 @@ def compare_images_with_similarity_threshold(baseline_img_path, generated_img_pa return similarity_ratio >= similarity_threshold -def render(file, outputfile): +def render(file, outputfile, disable_openpbr=False): """ Render a file using usdrecord. Parameters: file (str): File path to the asset to be rendered. outputfile (str): File path where the rendered output should be saved. + disable_openpbr (bool): Force UsdPreviewSurface output (see + OPENPBR_DISABLED_ASSETS). usdrecord runs in a subprocess, so this is + applied via the USD_FILEFORMATS_WRITE_OPENPBR env var; that also + reaches sbsar materials generated from a referenced .sbsar, which a + per-file SdfFileFormat arg on the root layer would not. """ + env = os.environ.copy() + if disable_openpbr: + env["USD_FILEFORMATS_WRITE_OPENPBR"] = "0" full_command = f'{RENDER_COMMAND} "{file}" "{outputfile}"' logging.info("Rendering: " + file + " To: " + outputfile) - os.system(full_command) + subprocess.run(full_command, shell=True, env=env) def run_usdchecker(file, results_file): @@ -147,16 +216,19 @@ def run_usdchecker(file, results_file): return result -def convert(input_path, converted_path): +def convert(input_path, converted_path, disable_openpbr=False): """ Convert an input file to the conerted_path format. Parameters: input_path (str): Input file path. converted_path (str): Output file path. + disable_openpbr (bool): Force UsdPreviewSurface output (see + OPENPBR_DISABLED_ASSETS) by passing writeOpenPBR=false to the plugin. Returns: bool: True if conversion was successful, False otherwise. """ - stage = Usd.Stage.Open(input_path) + open_path = with_openpbr_disabled(input_path) if disable_openpbr else input_path + stage = Usd.Stage.Open(open_path) if stage: stage.GetRootLayer().Export(converted_path) return True @@ -188,12 +260,14 @@ def process_file(plugin_name, test_file, generate_baseline, test_type): os.makedirs(output_path_folder, exist_ok=True) output_path = os.path.join(output_path_folder, os.path.splitext(os.path.basename(test_file))[0] + RENDER_OUTPUT_FORMAT) + disable_openpbr = is_openpbr_disabled(test_file) + if test_type == "basic": if plugin_name == "sbsar": threshhold = 0.75 else: threshhold = 0.9 - render(test_file, output_path) + render(test_file, output_path, disable_openpbr=disable_openpbr) if not generate_baseline: baseline_path = os.path.join(baseline_folder, relative_root, os.path.splitext(os.path.basename(test_file))[0] + RENDER_OUTPUT_FORMAT) if not compare_images_with_similarity_threshold(baseline_path, output_path, threshhold): @@ -206,7 +280,7 @@ def process_file(plugin_name, test_file, generate_baseline, test_type): converted_path = os.path.join(output_path_folder, f"{file_name}{CONVERTED_SUFFIX}{file_extension}") converted_output_path = os.path.join(output_path_folder, f"{file_name}{CONVERTED_SUFFIX}{RENDER_OUTPUT_FORMAT}") - convert(test_file, converted_path) + convert(test_file, converted_path, disable_openpbr=disable_openpbr) render(converted_path, converted_output_path) if not generate_baseline: @@ -310,7 +384,25 @@ def test_asset_rendering(plugin_name, filename): Returns: None: Asserts that no mismatching files are found. """ + if is_openpbr_disabled(filename): + warnings.warn( + f"OpenPBR output is temporarily disabled for '{filename}' (forcing " + "UsdPreviewSurface). MaterialX 'samplerCube' (mx_latlong_map_lookup) " + "does not compile on Metal; disabled on all platforms to keep " + "baselines consistent. Re-enable once building against USD 26.05 or " + "newer.", + UserWarning, + ) for test_type in ["basic", "roundtrip"]: + if test_type == "roundtrip" and is_roundtrip_disabled(filename): + warnings.warn( + f"Roundtrip test temporarily disabled for '{filename}': known " + "Phong->PBR conversion failure ('Invalid diffuse image' on a " + "material with no diffuse texture); fix in progress in the plugin " + "library.", + UserWarning, + ) + continue ret = process_file(plugin_name, filename, False, test_type) assert not (ret and ret.startswith("Error")), f"File mismatch in {test_type} test for {filename}: {ret}" diff --git a/utils/CMakeLists.txt b/utils/CMakeLists.txt index 6d0572bd..723d0235 100644 --- a/utils/CMakeLists.txt +++ b/utils/CMakeLists.txt @@ -25,9 +25,14 @@ if(DEFINED USD_FILEFORMATS_DEFAULT_WRITE_OPENPBR) target_compile_definitions(fileformatUtils PRIVATE USD_FILEFORMATS_DEFAULT_WRITE_OPENPBR=$) endif() +if(DEFINED USD_FILEFORMATS_DEFAULT_NATIVE_OPENPBR_PROCESSING) + target_compile_definitions(fileformatUtils PRIVATE + USD_FILEFORMATS_DEFAULT_NATIVE_OPENPBR_PROCESSING=$) +endif() usd_plugin_compile_config(fileformatUtils) set(_UTILS_HEADERS + "api.h" "assetresolver.h" "common.h" "debugCodes.h" @@ -44,6 +49,7 @@ set(_UTILS_HEADERS "layerWriteOpenPBR.h" "layerWriteSdfData.h" "materials.h" + "naming.h" "neuralAssetsHelper.h" "resolver.h" "sdfMaterialUtils.h" @@ -67,6 +73,7 @@ set(_UTILS_SOURCES "layerWriteOpenPBR.cpp" "layerWriteSdfData.cpp" "materials.cpp" + "naming.cpp" "neuralAssetsHelper.cpp" "resolver.cpp" "sdfMaterialUtils.cpp" @@ -98,7 +105,8 @@ PRIVATE target_include_directories(fileformatUtils PUBLIC - "${CMAKE_CURRENT_SOURCE_DIR}/include" + $ + $ PRIVATE "${PROJECT_BINARY_DIR}" ) @@ -130,19 +138,40 @@ if (USD_FILEFORMATS_ENABLE_PLY OR USD_FILEFORMATS_ENABLE_SPZ) endif() if(USD_FILEFORMATS_BUILD_TESTS) - target_sources(fileformatUtils - PRIVATE - "include/fileformatutils/test.h" - "src/test.cpp" + # Test-only helper (assertion utilities, openAssetStage, etc.). It is kept in a + # separate STATIC library rather than being folded into the SHARED fileformatUtils + # runtime library on purpose: GTest is statically linked, and embedding it in the + # shared library *and* in every test executable made GoogleTest's global flag + # objects be duplicated across modules and destroyed twice during static teardown + # ("double free or corruption (fasttop)" on Linux). Linking GTest only into the + # static helper (and the test executables) keeps a single set of those globals. + add_library(fileformatUtilsTest STATIC + "include/fileformatutils/test.h" + "src/test.cpp" ) - target_link_libraries(fileformatUtils + usd_plugin_compile_config(fileformatUtilsTest) + target_include_directories(fileformatUtilsTest PUBLIC - GTest::gtest # should separate usdutils-runtime part and usdutils-test part + $ + ) + target_link_libraries(fileformatUtilsTest + PUBLIC + fileformatUtils + GTest::gtest ) -endif() - -install(TARGETS fileformatUtils) -if(USD_FILEFORMATS_BUILD_TESTS) add_subdirectory(tests) endif() + +if(USD_FILEFORMATS_ENABLE_INSTALL) + set_property(TARGET fileformatUtils + APPEND PROPERTY + INSTALL_RPATH "${plugin_install_rpath_root}/." + ) + install(TARGETS fileformatUtils EXPORT usd-fileformats-targets) + install(FILES ${_UTILS_HEADERS} + DESTINATION + "${CMAKE_INSTALL_INCLUDEDIR}/usd-fileformats/utils" + COMPONENT Devel + ) +endif() diff --git a/utils/include/fileformatutils/featureFlags.h b/utils/include/fileformatutils/featureFlags.h index 633c024f..f13cb6f1 100644 --- a/utils/include/fileformatutils/featureFlags.h +++ b/utils/include/fileformatutils/featureFlags.h @@ -25,8 +25,8 @@ governing permissions and limitations under the License. /// are written by default. These are not experimental; they are stable /// configuration knobs. /// -/// 2. Experimental feature flags -- gates for in-development code paths that -/// are not yet ready for production use. +/// 2. Native OpenPBR processing -- controls whether importers and exporters +/// use OpenPbrMaterial directly or go through the ASM conversion layer. /// /// === Adding a new feature flag === /// @@ -65,31 +65,54 @@ PXR_NAMESPACE_OPEN_SCOPE // --------------------------------------------------------------------------- /// When true, UsdPreviewSurface material networks are written. +/// DEPRECATED: scheduled for removal. Set +/// USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING=1 to migrate; that flag forces +/// this setting off and USD_FILEFORMATS_WRITE_OPENPBR on automatically. Set +/// this setting to false to suppress the runtime deprecation warning without +/// migrating. extern PXR_NS::TfEnvSetting USD_FILEFORMATS_WRITE_USDPREVIEWSURFACE; /// When true, AdobeStandardMaterial (ASM) material networks are written. +/// DEPRECATED: scheduled for removal. Set +/// USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING=1 to migrate; that flag forces +/// this setting off and USD_FILEFORMATS_WRITE_OPENPBR on automatically. Set +/// this setting to false to suppress the runtime deprecation warning without +/// migrating. extern PXR_NS::TfEnvSetting USD_FILEFORMATS_WRITE_ASM; /// When true, OpenPBR / MaterialX material networks are written. extern PXR_NS::TfEnvSetting USD_FILEFORMATS_WRITE_OPENPBR; // --------------------------------------------------------------------------- -// Experimental feature flags +// Native OpenPBR processing // -// These gate in-development code paths. Each flag should be specific enough +// These gate native OpenPBR code paths. Each flag should be specific enough // that its name reflects what it affects. // --------------------------------------------------------------------------- -/// Gates experimental OpenPBR processing code paths (e.g. new writer/reader -/// logic that is not yet production-ready). This is separate from the material -/// model selection above -- USD_FILEFORMATS_WRITE_OPENPBR controls *whether* -/// OpenPBR is written, while this flag controls *how* it is processed. -extern PXR_NS::TfEnvSetting USD_FILEFORMATS_EXPERIMENTAL_OPENPBR_PROCESSING; +/// Gates native OpenPBR processing code paths. When enabled, importers populate +/// OpenPbrMaterial directly and exporters read from it, bypassing the ASM +/// Material conversion layer. This is separate from the material model selection +/// above -- USD_FILEFORMATS_WRITE_OPENPBR controls *whether* OpenPBR is written, +/// while this flag controls *how* it is processed. +extern PXR_NS::TfEnvSetting USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING; PXR_NAMESPACE_CLOSE_SCOPE namespace adobe::usd { +/// Returns true when USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING is enabled. +/// Use this instead of isFeatureEnabled(USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING) +/// to avoid Windows DLL symbol export issues. +USDFFUTILS_API bool +isNativeOpenPbrProcessingEnabled(); + +/// Returns true when USD_FILEFORMATS_WRITE_ASM is enabled. Use this instead of +/// isFeatureEnabled(USD_FILEFORMATS_WRITE_ASM) to avoid Windows DLL symbol export +/// issues. +USDFFUTILS_API bool +isWriteAsmEnabled(); + /// Query whether a specific feature flag is enabled. /// Wraps TfGetEnvSetting for a consistent, readable call site. template @@ -102,7 +125,25 @@ isFeatureEnabled(PXR_NS::TfEnvSetting& setting) /// Apply material model defaults from environment variables to the three /// write-material booleans. Call this in the default constructor of any struct /// that carries writeUsdPreviewSurface / writeASM / writeOpenPBR fields. +/// +/// Each USD_FILEFORMATS_WRITE_* env setting is read directly and the four flags +/// are independent. A once-per-process TF_WARN is emitted for each deprecated +/// setting that resolves to true. USDFFUTILS_API void applyMaterialModelDefaults(bool& writeUsdPreviewSurface, bool& writeASM, bool& writeOpenPBR); +/// Emit TF_WARN on the first call per process for each of the deprecated +/// material-write env settings that resolves to true, directing users at the +/// per-flag opt-out (set the deprecated flag to 0) as the migration path. +/// Subsequent calls are no-ops. A setting that resolves to false never emits a +/// warning. +USDFFUTILS_API void +warnOnceOnDeprecatedMaterialSettings(bool writeASM, bool writeUsdPreviewSurface); + +/// Test-only: reset the per-process "already warned" flags used by +/// warnOnceOnDeprecatedMaterialSettings so unit tests can exercise the +/// one-shot behavior repeatedly. +USDFFUTILS_API void +resetDeprecationWarningOnceFlagsForTesting(); + } diff --git a/utils/include/fileformatutils/images.h b/utils/include/fileformatutils/images.h index f7398e78..ab041b09 100644 --- a/utils/include/fileformatutils/images.h +++ b/utils/include/fileformatutils/images.h @@ -49,8 +49,9 @@ class USDFFUTILS_API Image bool isEmpty() const { return width == 0 || height == 0 || channels == 0; } /// Allocates memory for the pixel image data with dimensions \p width x \p height x \p - /// channels. - bool allocate(int width, int height, int channels); + /// channels. Rejects a zero dimension or a width*height*channels product too large to fit + /// back in a signed int, leaving the image empty and returning false. + bool allocate(unsigned int width, unsigned int height, unsigned int channels); /// Reads image data from an ImageAsset (which holds an encoded image like jpg, pgn, bmp, ...). bool read(const ImageAsset& imageAsset, int forceChannels = -1); diff --git a/utils/include/fileformatutils/layerWriteShared.h b/utils/include/fileformatutils/layerWriteShared.h index 177be6f8..d7351470 100644 --- a/utils/include/fileformatutils/layerWriteShared.h +++ b/utils/include/fileformatutils/layerWriteShared.h @@ -16,6 +16,9 @@ governing permissions and limitations under the License. #include #include +#include + +#include namespace adobe::usd { @@ -55,6 +58,13 @@ struct WriteSdfContext PXR_NS::SdfPathVector meshPrototypeMap; PXR_NS::SdfPathVector lightMap; + // Per-parent registry of already-used child prim names, keyed by parent prim path. Used to + // uniquify node names at write time against siblings the import-time uniquify pass cannot see + // (e.g. the synthesized "Materials" scope). Routes through UniqueNameEnforcer -> + // _makeUniqueAndAdd so the suffix algorithm stays single-sourced. + std::unordered_map + childNameEnforcers; + std::string srcAssetFilename; std::string debugTag; }; @@ -80,112 +90,6 @@ getTextureZeroVtValue(const PXR_NS::TfToken& channel); USDFFUTILS_API std::string createTexturePath(const std::string& srcAssetFilename, const std::string& imageUri); -/// @brief OpenPBR material struct -/// This is based on OpenPBR 1.0 -/// https://github.com/AcademySoftwareFoundation/OpenPBR/blob/44fe76650880914980402221672446ad44df15bd/reference/open_pbr_surface.mtlx -/// -/// The latest version can be found here (currently at 1.1) -/// https://github.com/AcademySoftwareFoundation/OpenPBR/blob/main/reference/open_pbr_surface.mtlx -/// -/// Note that there are additions at the bottom that are not from the OpenPBR spec, but that are -/// useful extensions to carry additional information that is important for the transcoding of -/// materials, especially for the backwards compatibility with ASM. -struct USDFFUTILS_API OpenPbrMaterial -{ - std::string name; - std::string displayName; - - // Note, the naming convention here follows the OpenPBR input names - Input base_weight; - Input base_color; - Input base_diffuse_roughness; - Input base_metalness; - Input specular_weight; - Input specular_color; - Input specular_roughness; - Input specular_ior; - Input specular_roughness_anisotropy; - Input transmission_weight; - Input transmission_color; - Input transmission_depth; - Input transmission_scatter; - Input transmission_scatter_anisotropy; - Input transmission_dispersion_scale; - Input transmission_dispersion_abbe_number; - Input subsurface_weight; - Input subsurface_color; - Input subsurface_radius; - Input subsurface_radius_scale; - Input subsurface_scatter_anisotropy; - Input fuzz_weight; - Input fuzz_color; - Input fuzz_roughness; - Input coat_weight; - Input coat_color; - Input coat_roughness; - Input coat_roughness_anisotropy; - Input coat_ior; - Input coat_darkening; - Input thin_film_weight; - Input thin_film_thickness; - Input thin_film_ior; - Input emission_luminance; - Input emission_color; - Input geometry_opacity; - Input geometry_thin_walled; - Input geometry_normal; - Input geometry_coat_normal; - Input geometry_tangent; - Input geometry_coat_tangent; - - /// The OpenPBR spec is only concerned with BXDF properties and hence does not have a - /// displacement input. But this can be expressed in MaterialX via displacement shader and - /// directly in other material models. - Input displacement; - - /// An occlusion signal is sometimes available for renderers that do implement their own global - /// illumination - Input occlusion; - - /// This is an ASM concept, which is hard to express in OpenPBR as the anisotropy direction is - /// derived from the tangent and not a texturable input of the angle. - /// We're keeping this for now until we have an actual transfer mechanism. - Input anisotropyAngle; - - /// This is an ASM concept, to control the strength of the specular reflection of the coat. - /// In OpenPBR some of this control is available via the coat_ior, but the equation is not - /// trivial and coat_ior or coatSpecularLevel could be a constant or textured - Input coatSpecularLevel; - - /// This is an ASM concept, with no correspondence in OpenPBR. It is designed for real-time - /// rasterizers to have an approximate notion of the depth of a absorbing/scattering object. - Input volumeThickness; - - /// This is an ASM concept, which can also be expressed via the scale of the normal Input. - /// We have it here for backwards compatibility, but should consider removing it. - float normalScale = 1.0f; - - /// This is a flag used by UsdPreviewSurface to switch between a metallic workflow, where the - /// specular color is derived from the base_color and a workflow that has an explicit - /// specular_color. - bool useSpecularWorkflow = false; - - /// This float value is used by UsdPreviewSurface to express alpha masking based on an opacity - /// texture that is thresholded by this value. If this is zero, normal opacity is used. If this - /// larger than 0.0 the masking will be used. This maps to the alphaCutoff value in GLTF. - float opacityThreshold = 0.0f; - - // Import of transmission from GLTF can activate the clearcoat lobe to model tinting of - // transmission, which ASM doesn't do automatically. If this was activated on import, we do - // not want to export clearcoat to GLTF again. - bool clearcoatModelsTransmissionTint = false; - - // Since USD doesn't support glTF unlit materials, we convert them on import to emissive. We - // keep this information, and store it as metadata in the file, so we can convert it back on - // export - bool isUnlit = false; -}; - /// @brief Converts a Material struct into an OpenPbrMaterial struct /// /// It implements a channel-by-channel mapping where there is a correspondence between the diff --git a/utils/include/fileformatutils/materials.h b/utils/include/fileformatutils/materials.h index 50d3bd4c..b5acab0f 100644 --- a/utils/include/fileformatutils/materials.h +++ b/utils/include/fileformatutils/materials.h @@ -16,6 +16,7 @@ governing permissions and limitations under the License. /// Set of material utilities. /// +#include "fileformatutils/api.h" #include "images.h" #include "usdData.h" @@ -68,6 +69,38 @@ class USDFFUTILS_API InputTranslator Input& out, bool intermediate = false); + /// Generates an output value equal to the per-channel product of two inputs. + /// If \p linearize is true, image samples are converted from sRGB to linear before + /// multiplication and the result is converted back to sRGB. Has no effect on constant + /// (non-image) inputs, which are always treated as linear. + bool translateProduct(const std::string& name, + const Input& in, + const Input& factor, + Input& out, + bool intermediate = false, + bool linearize = false); + + /// Generates an output value equal to the per-channel maximum of two inputs. + bool translateMax(const std::string& name, + const Input& in0, + const Input& in1, + Input& out, + bool intermediate = false); + + /// Generates an output value equal to the linear interpolation between two inputs using a + /// single-channel mask. out = in0 * (1 - mask) + in1 * mask + /// If \p linearize is true, image samples from in0 and in1 are converted from sRGB to linear + /// before interpolation and the result is converted back to sRGB. The mask is never linearized + /// since it is always a scalar weight. Has no effect on constant (non-image) inputs, which are + /// always treated as linear. + bool translateLerp(const std::string& name, + const Input& in0, + const Input& in1, + const Input& mask, + Input& out, + bool intermediate = false, + bool linearize = false); + /// Generates an output value equal to the scaled and biased input value. bool translateAffine(const std::string& name, const Input& in, @@ -92,6 +125,13 @@ class USDFFUTILS_API InputTranslator Input& metallicOut, Input& roughnessOut); + /// Computes only the roughness component of a Phong-to-PBR conversion, leaving metallic + /// untouched. Use this when the caller provides an explicit metallic/reflectionFactor value + /// and only needs shininess converted to PBR roughness. + bool translatePhong2Roughness(const Input& specularIn, + const Input& shininessIn, + Input& roughnessOut); + /// Generates a normal output value that is the same as the normal input value if present, /// or base on a bump input value otherwise. bool translateNormals(const Input& bumpIn, const Input& normalsIn, Input& normalsOut); @@ -105,6 +145,22 @@ class USDFFUTILS_API InputTranslator /// Generates an ambient output value based on an occlusion input value. bool translateAmbient2Occlusion(const Input& ambient, Input& occlusion); + /// Generates an output value by converting a multiscatter albedo input to a single-scatter + /// albedo input. + bool translateMultiscatterToSingleScatter(const std::string& name, + const Input& in, + float anisotropy, + Input& out, + bool intermediate = false); + + /// Generates an output value by converting a single-scatter albedo input to a multiscatter + /// albedo input. + bool translateSingleScatterToMultiscatter(const std::string& name, + const Input& in, + float anisotropy, + Input& out, + bool intermediate = false); + /// Generates an output value that is a mix from 4 input values. If those values are from a /// single image in the same order, name is not used, and instead the result will be identical /// to calling translateDirect. @@ -138,6 +194,7 @@ class USDFFUTILS_API InputTranslator int addImage(Image&& image, const std::string& assetName, + const std::string& assetUri, ImageFormat format, bool intermediate = false); @@ -155,6 +212,28 @@ class USDFFUTILS_API InputTranslator // Translate an input value directly to an output value. Helper function can be reused by // different translate functions regardless of which Input objects those take void translateDirectInternal(int imageIdx, Input& out); + + /// Describes one input slot for _applyImageOp. + struct _ImageOpSlot + { + const Input* input = nullptr; ///< Input to sample or evaluate as a constant. + int channels = 0; ///< Number of output channels to fill for this slot. + bool linearize = false; ///< If true, convert sRGB→linear after sampling. + }; + + /// Common implementation for multi-input image operations (product, max, lerp, …). + /// Handles cache lookup, image decode, UV transform, pixel loop, and output setup. + /// @p pixelFn is called per pixel as pixelFn(bufs, outPixel, outChannels), where bufs[i] + /// points to the slot.channels float values sampled or evaluated for slot i. + template + bool _applyImageOp(const std::string& name, + const std::string& opTag, + const std::string& extraKeySuffix, + std::initializer_list<_ImageOpSlot> slots, + int outChannels, + bool intermediate, + Input& out, + PixelFn pixelFn); }; // Map channel index to USD channel token @@ -165,4 +244,34 @@ channel2Token(int channel); USDFFUTILS_API int token2Channel(const PXR_NS::TfToken& token); -} \ No newline at end of file +// Van de Hulst approximation for converting between single-scattering albedo (as used by +// OpenPBR's transmission_scatter / subsurface_color) and multiple-scattering albedo (as used +// by KHR_materials_volume_scatter's multiscatterColorFactor). +// +// The forward approximation is: +// s = sqrt((1 - alpha) / (1 - alpha * g)) +// C = (1 - s)(1 - 0.139 * s) / (1 + 1.17 * s) +// where alpha is the single-scatter albedo, g is the anisotropy, and C is the +// multiple-scatter (diffuse) albedo. Analytically inverting this for alpha yields the +// polynomial form in multiscatterToSingleScatter(). +// +// Reference: +// Christopher Kulla and Alejandro Conty Estevez, "Revisiting Physically Based Shading +// at Imageworks", ACM SIGGRAPH 2017 Course: Physically Based Shading in Theory and +// Practice (2017). +// https://blog.selfshadow.com/publications/s2017-shading-course/imageworks/s2017_pbs_imageworks_slides_v2.pdf +// +// The underlying approximation originates from: +// H. C. van de Hulst, "Multiple Light Scattering", Academic Press (1980). +USDFFUTILS_API float +singleScatterToMultiscatter(float singleScatter, float anisotropy); + +USDFFUTILS_API PXR_NS::GfVec3f +singleScatterToMultiscatter(const PXR_NS::GfVec3f& singleScatter, float anisotropy); + +USDFFUTILS_API float +multiscatterToSingleScatter(float multiscatter, float anisotropy); + +USDFFUTILS_API PXR_NS::GfVec3f +multiscatterToSingleScatter(const PXR_NS::GfVec3f& multiscatter, float anisotropy); +} diff --git a/utils/include/fileformatutils/naming.h b/utils/include/fileformatutils/naming.h new file mode 100644 index 00000000..18656e8b --- /dev/null +++ b/utils/include/fileformatutils/naming.h @@ -0,0 +1,43 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#pragma once + +#include "api.h" + +#include + +namespace adobe::usd { + +/// Produce a USD-valid prim identifier from an arbitrary UTF-8 source string. +/// +/// Preserves Unicode characters that fall in XID_Start / XID_Continue (UAX #31); +/// replaces non-XID codepoints (and malformed UTF-8, which decodes to U+FFFD) with '_'. +/// Empty input returns "_". A leading codepoint that is XID_Continue but not +/// XID_Start (e.g. a digit) is preserved after a prepended '_'. +/// +/// Examples: +/// "Object_n3d#" -> "Object_n3d_" +/// "京都 Building" -> "京都_Building" +/// "Müller café" -> "Müller_café" +/// "2024_render" -> "_2024_render" +/// "🎨 art" -> "__art" +/// "" -> "_" +/// +/// Bridge utility: replace with the upstream sanitizer when Pixar's +/// tf_utf8_identifiers proposal lands in USD core. +/// +/// Uniqueness suffixing (the existing _001/_002 collision logic) is a separate +/// concern; sanitize first, then uniquify. +USDFFUTILS_API std::string +MakeValidUsdIdentifier(const std::string& source); + +} // namespace adobe::usd diff --git a/utils/include/fileformatutils/sdfUtils.h b/utils/include/fileformatutils/sdfUtils.h index 026c30f3..bc6102d2 100644 --- a/utils/include/fileformatutils/sdfUtils.h +++ b/utils/include/fileformatutils/sdfUtils.h @@ -14,6 +14,7 @@ governing permissions and limitations under the License. #include "api.h" #include "featureFlags.h" +#include #include #include #include @@ -213,6 +214,33 @@ setAttributeDefaultValue(PXR_NS::SdfAbstractData* data, setAttributeDefaultValue(data, propertyPath, untypedInValue, typeName); } +/// \ingroup utils_layer +/// Author a property dictionary verbatim into the prim's customData metadata at primPath. +/// +/// The dictionary is written as-is (no key validation, no value-type mapping, no per-entry +/// filtering) and recursively composed over any customData already on the prim: entries in +/// \p properties win on key collision, while existing sibling sub-trees are preserved, so +/// independent writers contributing under nested keys accumulate instead of clobbering one +/// another. Nested dictionaries and array values round-trip verbatim. An empty dictionary +/// authors nothing. The prim spec at primPath must already exist. +/// +/// Because the dictionary is authored without sanitization, callers handing in values derived +/// from untrusted file content are responsible for any key/value validation and size bounding. +USDFFUTILS_API void +writeCustomProperties(PXR_NS::SdfAbstractData* data, + const PXR_NS::SdfPath& primPath, + const PXR_NS::VtDictionary& properties); + +/// \ingroup utils_layer +/// Author a single key/value into the prim's customData metadata at primPath. Convenience wrapper +/// over writeCustomProperties with a one-entry dictionary, so a single-key write also preserves +/// any sibling customData already on the prim. +USDFFUTILS_API void +writeCustomProperty(PXR_NS::SdfAbstractData* data, + const PXR_NS::SdfPath& primPath, + const std::string& key, + const PXR_NS::VtValue& value); + /// Set the time sampled values for an animated attribute /// /// This takes a SdfTimeSampleMap, which completely describes the times and associated values of diff --git a/utils/include/fileformatutils/test.h b/utils/include/fileformatutils/test.h index 6747f398..868140ab 100644 --- a/utils/include/fileformatutils/test.h +++ b/utils/include/fileformatutils/test.h @@ -17,7 +17,14 @@ governing permissions and limitations under the License. /// These functions are as simple as they can be, and don't share code with the main body of code. /// -#include "api.h" +// test.h and its implementation (test.cpp) are built into the static, test-only +// fileformatUtilsTest helper library and linked directly into each test executable. +// Unlike the shared fileformatUtils runtime library, this helper needs no +// import/export decoration, so USDFFUTILSTEST_API expands to nothing. Keeping it +// out of the shared library is what prevents GoogleTest's global flag objects from +// being duplicated across the executable and libfileformatUtils.so (which caused a +// double free of those objects during static teardown on Linux). +#define USDFFUTILSTEST_API #include #include @@ -35,7 +42,7 @@ governing permissions and limitations under the License. surface)(UsdPreviewSurface)(useSpecularWorkflow)(diffuseColor)(emissiveColor)(specularColor)(normal)(metallic)(roughness)(clearcoat)(clearcoatRoughness)(opacity)(opacityThreshold)(displacement)(occlusion)(ior) PXR_NAMESPACE_OPEN_SCOPE -TF_DECLARE_PUBLIC_TOKENS(TestTokens, USDFFUTILS_API, TEST_TOKENS); +TF_DECLARE_PUBLIC_TOKENS(TestTokens, USDFFUTILSTEST_API, TEST_TOKENS); PXR_NAMESPACE_CLOSE_SCOPE #define ASSERT_PRIM(...) ASSERT_TRUE(assertPrim(__VA_ARGS__)) @@ -49,7 +56,13 @@ PXR_NAMESPACE_CLOSE_SCOPE #define ASSERT_DISPLAY_NAME(...) ASSERT_TRUE(assertDisplayName(__VA_ARGS__)) #define ASSERT_VISIBILITY(...) ASSERT_TRUE(assertVisibility(__VA_ARGS__)) #ifdef DO_RENDER -#define ASSERT_RENDER(filename, imageFilename) ASSERT_TRUE(assertRender(filename, imageFilename)) +// TODO:: We need to fix the issue of missing usdRecord within the environement. We will either need +// to package tools that this project depends on locally or we need to a way to specify the location +// of the python tools at run time for the tests + +// #define ASSERT_RENDER(filename, imageFilename) ASSERT_TRUE(assertRender(filename, imageFilename)) +#define ASSERT_RENDER(...) \ + {} #else #define ASSERT_RENDER(...) \ {} @@ -57,21 +70,21 @@ PXR_NAMESPACE_CLOSE_SCOPE // XXX This duplication of structs is highly suspicious template -struct USDFFUTILS_API ArrayData +struct USDFFUTILSTEST_API ArrayData { size_t size; PXR_NS::VtArray values; // a subset of the expected array data }; template -struct USDFFUTILS_API PrimvarData +struct USDFFUTILSTEST_API PrimvarData { PXR_NS::TfToken interpolation; ArrayData values; ArrayData indices; }; -struct USDFFUTILS_API MeshData +struct USDFFUTILSTEST_API MeshData { ArrayData faceVertexCounts; ArrayData faceVertexIndices; @@ -84,12 +97,12 @@ struct USDFFUTILS_API MeshData PrimvarData displayOpacity; }; -struct USDFFUTILS_API PointsData +struct USDFFUTILSTEST_API PointsData { size_t pointsCount; }; -struct USDFFUTILS_API InputData +struct USDFFUTILSTEST_API InputData { PXR_NS::VtValue value; int uvIndex; @@ -105,7 +118,7 @@ struct USDFFUTILS_API InputData std::string file; // a relative path to the current binary dir }; -struct USDFFUTILS_API MaterialData +struct USDFFUTILSTEST_API MaterialData { InputData useSpecularWorkflow; InputData diffuseColor; @@ -125,14 +138,14 @@ struct USDFFUTILS_API MaterialData InputData anisotropyLevel; }; -struct USDFFUTILS_API AnimationData +struct USDFFUTILSTEST_API AnimationData { std::map orient; std::map scale; std::map translate; }; -struct USDFFUTILS_API CameraData +struct USDFFUTILSTEST_API CameraData { PXR_NS::GfQuatf orient; PXR_NS::GfVec3f scale; @@ -147,7 +160,7 @@ struct USDFFUTILS_API CameraData float verticalAperture; }; -struct USDFFUTILS_API LightData +struct USDFFUTILSTEST_API LightData { // Light transformation data std::optional translation; @@ -166,23 +179,23 @@ struct USDFFUTILS_API LightData // ImageAsset texture }; -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertPrim(PXR_NS::UsdStageRefPtr stage, const std::string& path); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertNode(PXR_NS::UsdStageRefPtr stage, const std::string& path); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertMesh(PXR_NS::UsdStageRefPtr stage, const std::string& path, const MeshData& data); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertPoints(PXR_NS::UsdStageRefPtr stage, const std::string& path, const PointsData& data); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertMaterial(PXR_NS::UsdStageRefPtr stage, const std::string& path, const MaterialData& data); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertAnimation(PXR_NS::UsdStageRefPtr stage, const std::string& path, const AnimationData& data); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertCamera(PXR_NS::UsdStageRefPtr stage, const std::string& path, const CameraData& data); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertLight(PXR_NS::UsdStageRefPtr stage, const std::string& path, const LightData& data); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertDisplayName(PXR_NS::UsdStageRefPtr stage, const std::string& primPath, const std::string& displayName); @@ -196,24 +209,30 @@ assertDisplayName(PXR_NS::UsdStageRefPtr stage, * @param expectedActualVisibility If the prim is expected to be visible or invisible, when the * effective visibility is computed with UsdGeomImageable::ComputeVisibility() */ -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertVisibility(PXR_NS::UsdStageRefPtr stage, const std::string& path, bool expectedVisibilityAttr, bool expectedActualVisibility); -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertRender(const std::string& filename, const std::string& imageFilename); /// Compares a USD layer against a baseline USDA file. /// If generateBaseline is true, exports the layer to the baseline path instead of comparing. /// If dumpOnFailure is true, writes the actual output next to the baseline when comparison fails. -[[nodiscard]] USDFFUTILS_API ::testing::AssertionResult +[[nodiscard]] USDFFUTILSTEST_API ::testing::AssertionResult assertUsda(const PXR_NS::SdfLayerHandle& sdfLayer, const std::string& baselinePath, bool generateBaseline = false, bool dumpOnFailure = false); +[[nodiscard]] USDFFUTILSTEST_API PXR_NS::UsdStageRefPtr +openAssetStage(const std::string& path); + +[[nodiscard]] USDFFUTILSTEST_API PXR_NS::UsdStageRefPtr +openAssetStage(const std::string& path, const std::string& formatArgs); + template bool extractUsdAttribute(PXR_NS::UsdPrim prim, diff --git a/utils/include/fileformatutils/usdData.h b/utils/include/fileformatutils/usdData.h index 55615aaf..315352ce 100644 --- a/utils/include/fileformatutils/usdData.h +++ b/utils/include/fileformatutils/usdData.h @@ -27,6 +27,7 @@ governing permissions and limitations under the License. #include #include #include +#include #include #include #include @@ -34,6 +35,7 @@ governing permissions and limitations under the License. #include #include #include +#include #include namespace adobe::usd { @@ -92,6 +94,9 @@ struct USDFFUTILS_API Node std::string path; bool isJoint = false; + + // Pass-through properties, authored verbatim into the node prim's customData metadata. + PXR_NS::VtDictionary customProperties; }; /// \ingroup utils_geometry @@ -293,6 +298,7 @@ enum USDFFUTILS_API ImageFormat ImageFormatTga, ImageFormatTiff, ImageFormatWebp, + ImageFormatHdr, }; struct USDFFUTILS_API ImageAsset @@ -301,7 +307,6 @@ struct USDFFUTILS_API ImageAsset // packages. (example: if USD has @asset.fbx[image.png]@, uri would be image.png) std::string name; std::string uri; - ImageFormat format = ImageFormatUnknown; std::vector image; }; @@ -310,6 +315,11 @@ getFormat(const std::string& extension); USDFFUTILS_API std::string getFormatExtension(ImageFormat format); +// Map an image MIME type (e.g. "image/png") to an ImageFormat. Returns +// ImageFormatUnknown for an unmapped or empty spelling. +USDFFUTILS_API ImageFormat +getFormatFromMimeType(std::string_view mime); + enum USDFFUTILS_API LightType { Disk, @@ -438,6 +448,112 @@ struct USDFFUTILS_API Material Input scatteringDistanceScale; }; +/// @brief OpenPBR material struct +/// This is based on OpenPBR 1.0 +/// https://github.com/AcademySoftwareFoundation/OpenPBR/blob/44fe76650880914980402221672446ad44df15bd/reference/open_pbr_surface.mtlx +/// +/// The latest version can be found here (currently at 1.1) +/// https://github.com/AcademySoftwareFoundation/OpenPBR/blob/main/reference/open_pbr_surface.mtlx +/// +/// Note that there are additions at the bottom that are not from the OpenPBR spec, but that are +/// useful extensions to carry additional information that is important for the transcoding of +/// materials, especially for the backwards compatibility with ASM. +struct USDFFUTILS_API OpenPbrMaterial +{ + std::string name; + std::string displayName; + + // Note, the naming convention here follows the OpenPBR input names + Input base_weight; + Input base_color; + Input base_diffuse_roughness; + Input base_metalness; + Input specular_weight; + Input specular_color; + Input specular_roughness; + Input specular_ior; + Input specular_roughness_anisotropy; + Input transmission_weight; + Input transmission_color; + Input transmission_depth; + Input transmission_scatter; + Input transmission_scatter_anisotropy; + Input transmission_dispersion_scale; + Input transmission_dispersion_abbe_number; + Input subsurface_weight; + Input subsurface_color; + Input subsurface_radius; + Input subsurface_radius_scale; + Input subsurface_scatter_anisotropy; + Input fuzz_weight; + Input fuzz_color; + Input fuzz_roughness; + Input coat_weight; + Input coat_color; + Input coat_roughness; + Input coat_roughness_anisotropy; + Input coat_ior; + Input coat_darkening; + Input thin_film_weight; + Input thin_film_thickness; + Input thin_film_ior; + Input emission_luminance; + Input emission_color; + Input geometry_opacity; + Input geometry_thin_walled; + Input geometry_normal; + Input geometry_coat_normal; + Input geometry_tangent; + Input geometry_coat_tangent; + + /// The OpenPBR spec is only concerned with BXDF properties and hence does not have a + /// displacement input. But this can be expressed in MaterialX via displacement shader and + /// directly in other material models. + Input displacement; + + /// An occlusion signal is sometimes available for renderers that do implement their own global + /// illumination + Input occlusion; + + /// This is an ASM concept, which is hard to express in OpenPBR as the anisotropy direction is + /// derived from the tangent and not a texturable input of the angle. + /// We're keeping this for now until we have an actual transfer mechanism. + Input anisotropyAngle; + + /// This is an ASM concept, to control the strength of the specular reflection of the coat. + /// In OpenPBR some of this control is available via the coat_ior, but the equation is not + /// trivial and coat_ior or coatSpecularLevel could be a constant or textured + Input coatSpecularLevel; + + /// This is an ASM concept, with no correspondence in OpenPBR. It is designed for real-time + /// rasterizers to have an approximate notion of the depth of a absorbing/scattering object. + Input volumeThickness; + + /// This is an ASM concept, which can also be expressed via the scale of the normal Input. + /// We have it here for backwards compatibility, but should consider removing it. + float normalScale = 1.0f; + + /// This is a flag used by UsdPreviewSurface to switch between a metallic workflow, where the + /// specular color is derived from the base_color and a workflow that has an explicit + /// specular_color. + bool useSpecularWorkflow = false; + + /// This float value is used by UsdPreviewSurface to express alpha masking based on an opacity + /// texture that is thresholded by this value. If this is zero, normal opacity is used. If this + /// larger than 0.0 the masking will be used. This maps to the alphaCutoff value in GLTF. + float opacityThreshold = 0.0f; + + // Import of transmission from GLTF can activate the clearcoat lobe to model tinting of + // transmission, which ASM doesn't do automatically. If this was activated on import, we do + // not want to export clearcoat to GLTF again. + bool clearcoatModelsTransmissionTint = false; + + // Since USD doesn't support glTF unlit materials, we convert them on import to emissive. We + // keep this information, and store it as metadata in the file, so we can convert it back on + // export + bool isUnlit = false; +}; + /// \ingroup utils_layer /// \brief An aggregation of different caches of USD data. /// * During export, layerRead dumps data from the USD stage into this struct, for exporters to take @@ -467,6 +583,7 @@ struct USDFFUTILS_API UsdData std::vector images; std::vector lights; std::vector materials; + std::vector openPbrMaterials; std::vector skeletons; std::vector ngps; @@ -480,6 +597,7 @@ struct USDFFUTILS_API UsdData std::pair&> addPointSHCoeffSet(int meshIndex); std::pair addCurve(); std::pair addMaterial(); + std::pair addOpenPbrMaterial(); void reserveImages(size_t count); std::pair addImage(); std::pair addLight(); @@ -504,13 +622,17 @@ getInputValue(const Input& input, T* value) if constexpr (std::is_same_v) { *value = input.scale[0] * v + input.bias[0]; } else if constexpr (std::is_same_v) { - *value = PXR_NS::GfVec2f(input.scale[0], input.scale[1]) * v + - PXR_NS::GfVec2f(input.bias[0], input.bias[1]); + *value = PXR_NS::GfVec2f(v[0] * input.scale[0] + input.bias[0], + v[1] * input.scale[1] + input.bias[1]); } else if constexpr (std::is_same_v) { - *value = PXR_NS::GfVec3f(input.scale[0], input.scale[1], input.scale[2]) * v + - PXR_NS::GfVec3f(input.bias[0], input.bias[1], input.bias[2]); + *value = PXR_NS::GfVec3f(v[0] * input.scale[0] + input.bias[0], + v[1] * input.scale[1] + input.bias[1], + v[2] * input.scale[2] + input.bias[2]); } else if constexpr (std::is_same_v) { - *value = input.scale * v + input.bias; + *value = PXR_NS::GfVec4f(v[0] * input.scale[0] + input.bias[0], + v[1] * input.scale[1] + input.bias[1], + v[2] * input.scale[2] + input.bias[2], + v[3] * input.scale[3] + input.bias[3]); } else { return false; } @@ -527,8 +649,16 @@ printMaterial(const std::string& header, const PXR_NS::SdfPath& path, const Material& material, const std::string& debugTag); + +USDFFUTILS_API void +printOpenPbrMaterial(const std::string& header, + const PXR_NS::SdfPath& path, + const OpenPbrMaterial& material, + const std::string& debugTag); + USDFFUTILS_API void printMesh(const std::string& header, const Mesh& mesh, const std::string& debugTag); + USDFFUTILS_API void printCurve(const std::string& header, const Curve& curve, const std::string& debugTag); // void printImage(const std::string& header, const SdfPath& path, const ImageAsset& image); @@ -566,7 +696,7 @@ class USDFFUTILS_API UniqueNameEnforcer std::unordered_map namesMap; public: - void enforceUniqueness(std::string& name); + void enforceUniqueness(std::string& name, std::string* displayName = nullptr); }; // Remove any brackets from the file name as they are used as sentinels in the asset resolver diff --git a/utils/src/assetresolver.cpp b/utils/src/assetresolver.cpp index f905c03f..ea6ec2fa 100644 --- a/utils/src/assetresolver.cpp +++ b/utils/src/assetresolver.cpp @@ -13,6 +13,7 @@ governing permissions and limitations under the License. #include #include +#include #include using namespace PXR_NS; @@ -39,7 +40,12 @@ class ImageArAsset : public ArAsset virtual size_t Read(void* buffer, size_t count, size_t offset) const override { - return (size_t)memcpy(buffer, _data.data() + offset, count); + if (offset >= _data.size()) { + return 0; + } + count = std::min(count, _data.size() - offset); + memcpy(buffer, _data.data() + offset, count); + return count; } virtual std::pair GetFileUnsafe() const override diff --git a/utils/src/featureFlags.cpp b/utils/src/featureFlags.cpp index fc3ca289..f0842f25 100644 --- a/utils/src/featureFlags.cpp +++ b/utils/src/featureFlags.cpp @@ -11,16 +11,22 @@ governing permissions and limitations under the License. */ #include +#include + #ifndef USD_FILEFORMATS_DEFAULT_WRITE_USDPREVIEWSURFACE #define USD_FILEFORMATS_DEFAULT_WRITE_USDPREVIEWSURFACE true #endif #ifndef USD_FILEFORMATS_DEFAULT_WRITE_ASM -#define USD_FILEFORMATS_DEFAULT_WRITE_ASM true +#define USD_FILEFORMATS_DEFAULT_WRITE_ASM false #endif #ifndef USD_FILEFORMATS_DEFAULT_WRITE_OPENPBR -#define USD_FILEFORMATS_DEFAULT_WRITE_OPENPBR false +#define USD_FILEFORMATS_DEFAULT_WRITE_OPENPBR true +#endif + +#ifndef USD_FILEFORMATS_DEFAULT_NATIVE_OPENPBR_PROCESSING +#define USD_FILEFORMATS_DEFAULT_NATIVE_OPENPBR_PROCESSING true #endif PXR_NAMESPACE_OPEN_SCOPE @@ -37,14 +43,54 @@ TF_DEFINE_ENV_SETTING(USD_FILEFORMATS_WRITE_OPENPBR, (bool)(USD_FILEFORMATS_DEFAULT_WRITE_OPENPBR), "Write OpenPBR / MaterialX material networks by default"); -TF_DEFINE_ENV_SETTING(USD_FILEFORMATS_EXPERIMENTAL_OPENPBR_PROCESSING, - false, - "Enable experimental OpenPBR processing code paths"); +TF_DEFINE_ENV_SETTING(USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING, + (bool)(USD_FILEFORMATS_DEFAULT_NATIVE_OPENPBR_PROCESSING), + "Enable native OpenPBR processing code paths"); PXR_NAMESPACE_CLOSE_SCOPE namespace adobe::usd { +namespace { +std::atomic gWarnedAsm{ false }; +std::atomic gWarnedUsdPreviewSurface{ false }; +} + +bool +isNativeOpenPbrProcessingEnabled() +{ + return PXR_NS::TfGetEnvSetting(PXR_NS::USD_FILEFORMATS_NATIVE_OPENPBR_PROCESSING); +} + +bool +isWriteAsmEnabled() +{ + return PXR_NS::TfGetEnvSetting(PXR_NS::USD_FILEFORMATS_WRITE_ASM); +} + +void +warnOnceOnDeprecatedMaterialSettings(bool writeASM, bool writeUsdPreviewSurface) +{ + using namespace PXR_NS; + if (writeASM && !gWarnedAsm.exchange(true)) { + TF_WARN("USD_FILEFORMATS_WRITE_ASM is deprecated and will be removed in a future " + "release. Set USD_FILEFORMATS_WRITE_ASM=0 to suppress this warning and " + "stop writing ASM material networks."); + } + if (writeUsdPreviewSurface && !gWarnedUsdPreviewSurface.exchange(true)) { + TF_WARN("USD_FILEFORMATS_WRITE_USDPREVIEWSURFACE is deprecated and will be removed in " + "a future release. Set USD_FILEFORMATS_WRITE_USDPREVIEWSURFACE=0 to suppress " + "this warning and stop writing UsdPreviewSurface material networks."); + } +} + +void +resetDeprecationWarningOnceFlagsForTesting() +{ + gWarnedAsm.store(false); + gWarnedUsdPreviewSurface.store(false); +} + void applyMaterialModelDefaults(bool& writeUsdPreviewSurface, bool& writeASM, bool& writeOpenPBR) { @@ -52,6 +98,7 @@ applyMaterialModelDefaults(bool& writeUsdPreviewSurface, bool& writeASM, bool& w PXR_NS::TfGetEnvSetting(PXR_NS::USD_FILEFORMATS_WRITE_USDPREVIEWSURFACE); writeASM = PXR_NS::TfGetEnvSetting(PXR_NS::USD_FILEFORMATS_WRITE_ASM); writeOpenPBR = PXR_NS::TfGetEnvSetting(PXR_NS::USD_FILEFORMATS_WRITE_OPENPBR); + warnOnceOnDeprecatedMaterialSettings(writeASM, writeUsdPreviewSurface); } } diff --git a/utils/src/geometry.cpp b/utils/src/geometry.cpp index 2939585f..e9940b49 100644 --- a/utils/src/geometry.cpp +++ b/utils/src/geometry.cpp @@ -772,12 +772,12 @@ computeSmoothNormals(Mesh& mesh) // we precompute the prev and current values and then move them forward by one in each // iteration int prevIndex = mesh.indices[faceVertexIndexBase + (numFaceVertices - 1)]; - if (prevIndex >= vertexCount) { + if (prevIndex < 0 || static_cast(prevIndex) >= vertexCount) { continue; } GfVec3f prevP = mesh.points[prevIndex]; int currentIndex = mesh.indices[faceVertexIndexBase]; - if (currentIndex >= vertexCount) { + if (currentIndex < 0 || static_cast(currentIndex) >= vertexCount) { continue; } GfVec3f currentP = mesh.points[currentIndex]; @@ -785,7 +785,7 @@ computeSmoothNormals(Mesh& mesh) for (int i = 0; i < numFaceVertices; ++i) { // Compute the next index and position int nextIndex = mesh.indices[faceVertexIndexBase + (i + 1) % numFaceVertices]; - if (nextIndex >= vertexCount) { + if (nextIndex < 0 || static_cast(nextIndex) >= vertexCount) { continue; } GfVec3f nextP = mesh.points[nextIndex]; diff --git a/utils/src/images.cpp b/utils/src/images.cpp index b2d620d0..df3c1280 100644 --- a/utils/src/images.cpp +++ b/utils/src/images.cpp @@ -12,10 +12,12 @@ governing permissions and limitations under the License. #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -24,6 +26,39 @@ using namespace PXR_NS; namespace adobe::usd { +namespace { +// Validates width/height/channels (unsigned, so a negative value can't reach here in the first +// place) and computes their product using overflow-checked, division-guarded multiplication. +// A single wider intermediate isn't enough on its own: three uint32_t values near UINT32_MAX +// multiplied together can still exceed UINT64_MAX ((2^32-1)^3 > 2^64-1), so each multiplication +// step is checked *before* it's performed rather than checked after the fact. +// Downstream code (transformChannel, Image::set, computeRange) assumes the product fits back +// into a signed 32-bit int, so that's the ceiling enforced here. +bool +validateImageDimensions(unsigned int width, + unsigned int height, + unsigned int channels, + size_t& pixelCount) +{ + if (width == 0 || height == 0 || channels == 0) { + return false; + } + constexpr uint64_t maxProduct = static_cast(std::numeric_limits::max()); + uint64_t w = width; + uint64_t h = height; + uint64_t c = channels; + if (w > maxProduct / h) { + return false; + } + uint64_t widthHeight = w * h; + if (widthHeight > maxProduct / c) { + return false; + } + pixelCount = static_cast(widthHeight * c); + return true; +} +} + Image::Image() : width(0U) , height(0U) @@ -33,12 +68,27 @@ Image::Image() Image::~Image() {} bool -Image::allocate(int width, int height, int channels) +Image::allocate(unsigned int width, unsigned int height, unsigned int channels) { - this->width = width; - this->height = height; - this->channels = channels; - pixels.resize(width * height * channels); + size_t pixelCount = 0; + if (!validateImageDimensions(width, height, channels, pixelCount)) { + TF_WARN("Image::allocate() rejected invalid or oversized dimensions: width=%u, " + "height=%u, channels=%u", + width, + height, + channels); + this->width = 0; + this->height = 0; + this->channels = 0; + pixels.clear(); + return false; + } + // validateImageDimensions guarantees the product (and therefore each factor) fits in a + // signed int, so these narrowing casts can't overflow. + this->width = static_cast(width); + this->height = static_cast(height); + this->channels = static_cast(channels); + pixels.resize(pixelCount); return !pixels.empty(); } @@ -72,14 +122,34 @@ Image::read(const ImageAsset& imageAsset, int forceChannels) return false; } const OIIO::ImageSpec& spec = input->spec(); + int newChannels = forceChannels > 0 ? forceChannels : spec.nchannels; + size_t pixelCount = 0; + // OIIO's ImageSpec fields are signed; reject negative values explicitly rather than letting + // them wrap when narrowed to the unsigned validateImageDimensions() parameters. + bool validDimensions = spec.width >= 0 && spec.height >= 0 && newChannels >= 0 && + validateImageDimensions(static_cast(spec.width), + static_cast(spec.height), + static_cast(newChannels), + pixelCount); + if (!validDimensions) { + TF_WARN("Image::read() rejected invalid or oversized dimensions for URI=%s: width=%d, " + "height=%d, channels=%d", + imageAsset.uri.c_str(), + spec.width, + spec.height, + newChannels); + input->close(); + width = 0; + height = 0; + channels = 0; + pixels.clear(); + return false; + } width = spec.width; height = spec.height; - channels = spec.nchannels; - if (forceChannels > 0) { - // note we force to forceChannels, instead of the true spec.nchannels - channels = forceChannels; - } - pixels.resize(width * height * channels); + // note we force to forceChannels, instead of the true spec.nchannels, when requested + channels = newChannels; + pixels.resize(pixelCount); input->read_image(0, 0, 0, channels, OIIO::TypeDesc::FLOAT, pixels.data()); input->close(); return true; @@ -242,6 +312,9 @@ Image::transformChannel(const Image& imageSrc, if (width != imageSrc.width || height != imageSrc.height || channelSrc >= imageSrc.channels || channelDst >= channels) return false; + // width/height/channels are public, but every call site in this codebase only ever sets them + // via the validated allocate()/read(), which guarantee width * height * channels fits in a + // signed int, so this can't overflow either as long as that convention holds. const uint32_t pixelCount = width * height; const float* src = imageSrc.pixels.data(); const int numSrcChannels = imageSrc.channels; @@ -265,6 +338,7 @@ Image::transformChannel(const Image& imageSrc, void Image::set(float r, float g, float b, float a) { + // See transformChannel() above: width/height/channels aren't expected to overflow here. int pixelCount = width * height; float* dst = pixels.data(); switch (channels) { diff --git a/utils/src/layerRead.cpp b/utils/src/layerRead.cpp index 3f89680a..c9792ca7 100644 --- a/utils/src/layerRead.cpp +++ b/utils/src/layerRead.cpp @@ -921,7 +921,14 @@ readPointInstancer(ReadLayerContext& ctx, const UsdPrim& prim, int parent) node.staticMeshes.push_back(protoIndex); } else { auto [nodeIndex, node] = ctx.usd->getParent(parent); - node.staticMeshes.push_back(protoIndex); + // Identity (or zero) transforms collapse every instance onto this + // single parent node. Push each prototype mesh at most once so + // downstream exporters don't emit duplicate geometry and duplicate + // material bindings. + if (std::find(node.staticMeshes.begin(), node.staticMeshes.end(), protoIndex) == + node.staticMeshes.end()) { + node.staticMeshes.push_back(protoIndex); + } } } return true; @@ -1456,7 +1463,7 @@ splitAnimationTracks(UsdData& usd) std::vector splitSkeletonAnimations; for (SkeletonAnimation& skeletonAnimation : skeleton.skeletonAnimations) { - for (int animationTrackIndex = 0; animationTrackIndex < usd.animationTracks.size(); + for (size_t animationTrackIndex = 0; animationTrackIndex < usd.animationTracks.size(); animationTrackIndex++) { AnimationTrack& track = usd.animationTracks[animationTrackIndex]; float mainMinTime = track.minTime + track.offsetToJoinedTimeline; @@ -1465,7 +1472,7 @@ splitAnimationTracks(UsdData& usd) splitSkeletonAnimations.push_back(SkeletonAnimation()); SkeletonAnimation& filteredAnimation = splitSkeletonAnimations.back(); - int t = 0; + size_t t = 0; for (const float time : skeletonAnimation.times) { if (skeletonAnimation.translations.size() <= t || skeletonAnimation.rotations.size() <= t || diff --git a/utils/src/layerReadMaterial.cpp b/utils/src/layerReadMaterial.cpp index 288efd38..b1d6affd 100644 --- a/utils/src/layerReadMaterial.cpp +++ b/utils/src/layerReadMaterial.cpp @@ -799,12 +799,19 @@ readMaterial(ReadLayerContext& ctx, const UsdPrim& prim) // Question: when the reading fails, should the material be removed from the UsdData? // Currently we keep a partially parsed Material in there. - auto [materialIndex, outputMaterial] = ctx.usd->addMaterial(); - ctx.materials[prim.GetPath().GetString()] = materialIndex; - outputMaterial = mapOpenPbrMaterialStructToMaterialStruct(material); - - printMaterial("layer::read", prim.GetPath(), outputMaterial, ctx.debugTag); - return success; + if (isNativeOpenPbrProcessingEnabled()) { + auto [materialIndex, outputMaterial] = ctx.usd->addOpenPbrMaterial(); + ctx.materials[prim.GetPath().GetString()] = materialIndex; + outputMaterial = material; + printOpenPbrMaterial("layer::read", prim.GetPath(), outputMaterial, ctx.debugTag); + return success; + } else { + auto [materialIndex, outputMaterial] = ctx.usd->addMaterial(); + ctx.materials[prim.GetPath().GetString()] = materialIndex; + outputMaterial = mapOpenPbrMaterialStructToMaterialStruct(material); + printMaterial("layer::read", prim.GetPath(), outputMaterial, ctx.debugTag); + return success; + } } } \ No newline at end of file diff --git a/utils/src/layerReadMaterialUtils.cpp b/utils/src/layerReadMaterialUtils.cpp index 81d6c426..d64e5d6f 100644 --- a/utils/src/layerReadMaterialUtils.cpp +++ b/utils/src/layerReadMaterialUtils.cpp @@ -229,7 +229,18 @@ readImage(ReadLayerContext& ctx, const SdfAssetPath& assetPath) // SBSAR images are a special cases where the data is stored raw and must be transcoded to a // different image in memory extension = getSbsarImageExtension(resolvedAssetPath); - transcodeImageAssetToMemory(resolvedAssetPath, image.uri, image.image); + if (!extension.empty()) { + // Build a proper filename with the transcoded extension (e.g. + // "material_roughness.png"). image.uri has not been assigned yet at this point, so it + // cannot be used as the filename. + std::string transcodedFilename = name + "." + extension; + transcodeImageAssetToMemory(resolvedAssetPath, transcodedFilename, image.image); + // Update filePath so that image.uri uses the transcoded extension, not ".sbsarimage" + filePath = transcodedFilename; + } else { + TF_WARN("Could not determine transcoded extension for sbsarimage: %s", + resolvedAssetPath.c_str()); + } } else { auto asset = ArGetResolver().OpenAsset(ArResolvedPath(resolvedAssetPath)); if (asset) { diff --git a/utils/src/layerWriteOpenPBR.cpp b/utils/src/layerWriteOpenPBR.cpp index 5d22d5f7..14112f8d 100644 --- a/utils/src/layerWriteOpenPBR.cpp +++ b/utils/src/layerWriteOpenPBR.cpp @@ -157,6 +157,7 @@ createMaterialXTextureReader(SdfAbstractData* sdfData, GfVec4f scale = input.scale; GfVec4f bias = input.bias; + float normalMapScale = 1.0f; if (isNormalMap) { // In MaterialX, the ND_normalmap node, which is downstream of the ND_UsdUVTexture_23 will // decode the normal from the raw texture value, assuming the OpenGL convention, using a @@ -165,14 +166,25 @@ createMaterialXTextureReader(SdfAbstractData* sdfData, // // We have these decoding scale and bias values in our Input struct, especially if we're // trying to differentiate it from a DirectX encoded normalmap and/or a normal strength - // multiplier. So we apply the inverse affine transform using the OpenGL decoding values, - // which yields a scale of 1 and a bias of 0, if it was indeed the OpenGL convention. In the - // case of something else it will yield a transformation to something that can be decoding - // with the OpenGL convention. Thus we can represent DirectX encoding and multipliers. + // multiplier (e.g. glTF KHR_materials_normalTexture.scale). So we apply the inverse affine + // transform using the OpenGL decoding values, which yields a scale of 1 and a bias of 0, if + // it was indeed the OpenGL convention. In the case of something else it will yield a + // transformation to something that can be decoded with the OpenGL convention. Thus we can + // represent DirectX encoding and multipliers. // // Note that this mirrors the process in the OpenPBR reading code. scale = GfCompDiv(scale, kOpenGLNormalTexScale); bias = GfCompDiv(bias - kOpenGLNormalTexBias, kOpenGLNormalTexScale); + + // If the residual encodes only a normalScale strength multiplier (XY components equal and + // positive), extract it and pass it to ND_normalmap's scale input instead — this is the + // standard MaterialX pattern for normal strength. If the residual encodes something else + // (e.g. DirectX Y inversion where scale[1] is negative), keep it on the texture reader. + if (scale[0] > 0.0f && scale[0] == scale[1]) { + normalMapScale = scale[0]; + scale = kDefaultTexScale; + bias = kDefaultTexBias; + } } if (scale != kDefaultTexScale) { inputValues.emplace_back("scale", scale); @@ -194,8 +206,8 @@ createMaterialXTextureReader(SdfAbstractData* sdfData, if (isNormalMap || isTangentMap) { // The rgb output of the ND_UsdUVTexture_23 is of type color3, but the ND_normalmap node - // for normal maps and the tangent map input on the surface require vector3. So we inject a - // simple type conversion node for correctness. + // requires that the normal and tangent inputs are vector3. So we inject a simple type + // conversion node to convert the color3 to a vector3 for correctness. textureOutput = createShader(sdfData, parentPath, TfToken(name.GetString() + "_as_vector"), @@ -203,17 +215,21 @@ createMaterialXTextureReader(SdfAbstractData* sdfData, "out", {}, { { "in", textureOutput } }); - } - if (isNormalMap) { - // The texture reader for a normal map reads a texture map in tangent space, which needs - // to be transformed into world space. Route normal map through a normal map node. + // The texture reader for a normal or tangent map reads a texture map in tangent space, + // which needs to be transformed into world space. Route the normal or tangent map through a + // normal map node. For normal maps, the strength multiplier (normalScale) is passed via + // ND_normalmap's scale input rather than baking it into the texture reader's scale/bias. + InputValues normalmapValues; + if (isNormalMap && normalMapScale != 1.0f) { + normalmapValues.emplace_back("scale", normalMapScale); + } textureOutput = createShader(sdfData, parentPath, TfToken(name.GetString() + "_to_world_space"), MtlXTokens->ND_normalmap, "out", - {}, + normalmapValues, { { "in", textureOutput } }); } diff --git a/utils/src/layerWriteSdfData.cpp b/utils/src/layerWriteSdfData.cpp index 9751541d..64ef07e7 100644 --- a/utils/src/layerWriteSdfData.cpp +++ b/utils/src/layerWriteSdfData.cpp @@ -16,11 +16,13 @@ governing permissions and limitations under the License. #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -32,6 +34,7 @@ governing permissions and limitations under the License. #include #include +#include #include #include @@ -396,11 +399,25 @@ _writeTimeSamples(SdfAbstractData* sdfData, const TimeValues& timeValues) { if (!timeValues.times.empty()) { + // Defense-in-depth: a TimeValues with mismatched times/values sizes can be produced + // by malformed input (e.g. a glTF animation sampler whose input and output accessors + // disagree on count). Indexing values[i] using times.size() as the bound would read + // past the values buffer. Clamp to the shorter of the two. + const size_t pairedCount = std::min(timeValues.times.size(), timeValues.values.size()); + if (pairedCount != timeValues.times.size()) { + TF_WARN("TimeValues for <%s> has %zu times but %zu values; truncating to %zu samples", + propertyPath.GetText(), + timeValues.times.size(), + timeValues.values.size(), + pairedCount); + } SdfTimeSampleMap timeSamples; - for (size_t i = 0; i < timeValues.times.size(); ++i) { + for (size_t i = 0; i < pairedCount; ++i) { timeSamples.emplace(timeValues.times[i], VtValue(CT(timeValues.values[i]))); } - setAttributeTimeSampledValues(sdfData, propertyPath, timeSamples); + if (!timeSamples.empty()) { + setAttributeTimeSampledValues(sdfData, propertyPath, timeSamples); + } } } @@ -688,6 +705,18 @@ _writeMesh(SdfAbstractData* sdfData, createAttr(UsdGeomTokens->points, SdfValueTypeNames->Point3fArray, mesh.points); createAttr(UsdGeomTokens->faceVertexCounts, SdfValueTypeNames->IntArray, mesh.faces); createAttr(UsdGeomTokens->faceVertexIndices, SdfValueTypeNames->IntArray, mesh.indices); + + // Author extent so downstream UsdGeomBBoxCache queries are O(1); without it bounds are + // re-derived from every vertex on each selection, framing and gizmo query. + if (!mesh.points.empty()) { + GfRange3f extent; + for (const GfVec3f& pt : mesh.points) { + extent.UnionWith(pt); + } + VtVec3fArray extentArray{ extent.GetMin(), extent.GetMax() }; + createAttr(UsdGeomTokens->extent, SdfValueTypeNames->Float3Array, extentArray); + } + // Subdivision rules createAttr( UsdGeomTokens->subdivisionScheme, SdfValueTypeNames->Token, UsdGeomTokens->none, true); @@ -839,7 +868,7 @@ _writeInstancedMesh(WriteSdfContext& ctx, // but after tests, seems USD is really column-major, as are its transforms. // So really: u0v0, u1v0, ... uxv0, u0v1, ... SdfPath -_writeNurb(SdfAbstractData* sdfData, const SdfPath& parentPath, NurbData& nurb) +_writeNurb(SdfAbstractData* sdfData, const SdfPath& parentPath, const NurbData& nurb) { SdfPath primPath = createPrimSpec(sdfData, parentPath, TfToken(nurb.name), UsdGeomTokens->NurbsPatch); @@ -987,17 +1016,24 @@ _createNode(WriteSdfContext& ctx, std::vector& childPaths, std::vector& children) { - TfToken child(node.name); + // Uniquify the node name against siblings already created under this parent (including the + // synthesized "Materials" scope, which the import-time uniquify pass cannot see). The same + // uniquified name must be used for the spec path AND the child token added to the parent's + // primChildren below, or the two disagree and produce invalid USDC. Display name is preserved. + std::string nodeName = node.name; + std::string displayName = node.displayName; + ctx.childNameEnforcers[parentPath].enforceUniqueness(nodeName, &displayName); + + TfToken child(nodeName); SdfPath primPath = createPrimSpec(ctx.sdfData, parentPath, - TfToken(node.name), + child, UsdGeomTokens->Xform, PXR_NS::SdfSpecifier::SdfSpecifierDef, /* append = */ false); - if (!node.displayName.empty()) { - setPrimMetadata( - ctx.sdfData, primPath, SdfFieldKeys->DisplayName, VtValue(node.displayName)); + if (!displayName.empty()) { + setPrimMetadata(ctx.sdfData, primPath, SdfFieldKeys->DisplayName, VtValue(displayName)); } int nodeIndex = std::distance(ctx.usdData->nodes.data(), &node); @@ -1022,6 +1058,10 @@ _writeNode(WriteSdfContext& ctx, const SdfPath& primPath, const Node& node) ctx.sdfData, primPath, SdfFieldKeys->DisplayName, VtValue(node.displayName)); } + if (!node.customProperties.empty()) { + writeCustomProperties(ctx.sdfData, primPath, node.customProperties); + } + if (node.camera >= 0) { _writeCamera(ctx.sdfData, primPath, ctx.usdData->cameras[node.camera]); } @@ -1059,6 +1099,12 @@ _writeNode(WriteSdfContext& ctx, const SdfPath& primPath, const Node& node) _writeCurve(ctx, primPath, curve); } + // NURBS patches + for (int nurbIndex : node.nurbs) { + const NurbData& nurb = ctx.usdData->nurbs[nurbIndex]; + _writeNurb(ctx.sdfData, primPath, nurb); + } + _writeNodes(ctx, primPath, node.children); return true; @@ -1365,7 +1411,7 @@ _writeAnimationTracks(const WriteLayerOptions& options, UsdData& data) } auto joinTimeValues = [&track](const auto& srcTimeValues, auto& dstTimeValues) { - int t = 0; + size_t t = 0; for (const float time : srcTimeValues.times) { if (srcTimeValues.values.size() <= t) { @@ -1404,7 +1450,7 @@ _writeAnimationTracks(const WriteLayerOptions& options, UsdData& data) continue; } - int t = 0; + size_t t = 0; for (const float time : skeletonAnimation.times) { if (skeletonAnimation.translations.size() <= t || skeletonAnimation.rotations.size() <= t || @@ -1451,7 +1497,7 @@ _writeLayerSdfData(const WriteLayerOptions& options, createPseudoRootSpec(sdfData); std::string layerStem = TfStringGetBeforeSuffix(TfGetBaseName(layerName)); - TfToken rootNodeName = TfToken(TfMakeValidIdentifier(layerStem)); + TfToken rootNodeName = TfToken(MakeValidUsdIdentifier(layerStem)); SdfPath rootNodePath = createPrimSpec(sdfData, SdfPath::AbsoluteRootPath(), rootNodeName, UsdGeomTokens->Xform); @@ -1468,6 +1514,10 @@ _writeLayerSdfData(const WriteLayerOptions& options, ctx.materialMap.resize(usdData.materials.size()); TfToken materialsPrimName("Materials"); SdfPath materialsPath = createPrimSpec(sdfData, rootNodePath, materialsPrimName); + // Reserve the scope name under the root so a sibling node named "Materials" is uniquified + // (renamed) rather than colliding with this scope. See _createNode. + std::string reservedMaterialsName = materialsPrimName.GetString(); + ctx.childNameEnforcers[rootNodePath].enforceUniqueness(reservedMaterialsName); int i = 0; for (const Material& material : usdData.materials) { const OpenPbrMaterial openPbrMaterial = @@ -1477,12 +1527,29 @@ _writeLayerSdfData(const WriteLayerOptions& options, SdfPath materialPath = materialsPath.AppendChild(TfToken(material.name)); printMaterial("layer::write", materialPath, material, ctx.debugTag); } + } else if (!usdData.openPbrMaterials.empty()) { + ctx.materialMap.resize(usdData.openPbrMaterials.size()); + TfToken materialsPrimName("Materials"); + SdfPath materialsPath = createPrimSpec(sdfData, rootNodePath, materialsPrimName); + // Reserve the scope name under the root so a sibling node named "Materials" is uniquified + // (renamed) rather than colliding with this scope. See _createNode. + std::string reservedMaterialsName = materialsPrimName.GetString(); + ctx.childNameEnforcers[rootNodePath].enforceUniqueness(reservedMaterialsName); + int i = 0; + for (const OpenPbrMaterial& material : usdData.openPbrMaterials) { + ctx.materialMap[i++] = _writeMaterial(ctx, materialsPath, material); + + SdfPath materialPath = materialsPath.AppendChild(TfToken(material.name)); + printOpenPbrMaterial("layer::write", materialPath, material, ctx.debugTag); + } } phaseSW.Stop(); + const size_t materialCount = + usdData.materials.empty() ? usdData.openPbrMaterials.size() : usdData.materials.size(); TF_DEBUG_MSG(FILE_FORMAT_UTIL, "_writeLayerSdfData materials time: %ld ms (%zu materials)\n", static_cast(phaseSW.GetMilliseconds()), - usdData.materials.size()); + materialCount); phaseSW.Reset(); // This map is filled with paths to prototypes as we process instanceable meshes @@ -1558,9 +1625,27 @@ _writeLayerSdfData(const WriteLayerOptions& options, // If requested, write the images to files on disk if (!options.assetsPath.empty() && usdData.images.size()) { + const std::filesystem::path baseDir = + std::filesystem::path(options.assetsPath).lexically_normal(); TfMakeDirs(options.assetsPath, -1, true); for (const ImageAsset& image : usdData.images) { - std::filesystem::path filepath = std::filesystem::path(options.assetsPath) / image.uri; + const std::filesystem::path filepath = (baseDir / image.uri).lexically_normal(); + // image.uri can originate in untrusted file content, so confirm it stays + // inside assetsPath before creating directories or writing: an absolute + // uri or one containing ".." would otherwise let a crafted file write + // outside the chosen export directory. This is a lexical check on the uri + // (it blocks ".." and absolute paths); it does not resolve symlinks within + // assetsPath, which is the caller-chosen, trusted export directory. + const std::filesystem::path rel = filepath.lexically_relative(baseDir); + if (rel.empty() || *rel.begin() == ".." || rel == ".") { + TF_WARN("Refusing to write image %s outside assetsPath %s", + image.uri.c_str(), + options.assetsPath.c_str()); + continue; + } + // image.uri may carry subdirectories (e.g. a package-relative path), so + // create the file's parent chain rather than only assetsPath itself. + TfMakeDirs(filepath.parent_path().string(), -1, true); if (!writeDataToDisk(filepath, image.image.data(), image.image.size())) { TF_WARN( "Could not write image %s to %s", image.uri.c_str(), options.assetsPath.c_str()); diff --git a/utils/src/materials.cpp b/utils/src/materials.cpp index 36ad2394..23a075cd 100644 --- a/utils/src/materials.cpp +++ b/utils/src/materials.cpp @@ -13,6 +13,7 @@ governing permissions and limitations under the License. #include #include #include +#include #include #include @@ -77,6 +78,10 @@ input2key(int imageIndex, int channelIndex) return token.GetString(); } +// Mirror threshold for Phong-to-PBR roughness conversion +// Shininess > ~600 produces roughness < 0.08, which should be treated as a perfect mirror +static constexpr float MIRROR_THRESHOLD = 0.08f; + // Phong to PBR conversion, taken from: // https://docs.microsoft.com/en-us/azure/remote-rendering/reference/material-mapping void @@ -117,6 +122,12 @@ phongToPbr(const Image& diffuse, float specularStrength = std::max(specR, std::max(specG, specB)); float rou = sqrt(2 / (shininessFactor * shin * specularIntensity + 2)); + // Treat near-zero roughness (high Phong shininess) as a perfect mirror. Kept consistent + // with the value-based overload below. + if (rou < MIRROR_THRESHOLD) { + rou = 0.0; + } + float specComplement = 1 - specularStrength; float A = dsr; float B = (diffuseBrightness * (specComplement / (1 - A)) + specularBrightness) - 2 * A; @@ -158,13 +169,10 @@ phongToPbr(const GfVec3f& diffuse, float& metallic, float shininessFactor) { - // Attenuate specular and shininess, so higher metallics are not excessive (experimental) - float k = .5; - specular = GfVec3f(specular[0] - k * specular[0] * specular[0], - specular[1] - k * specular[1] * specular[1], - specular[2] - k * specular[2] * specular[2]); - float k2 = .5; - shininess = shininess - k2 * shininess * shininess / 1000; + // The experimental specular/shininess attenuation that used to live here has been removed. + // It muted bright speculars (white chrome read as gray metal, cyan read as muted cyan) and + // could drive shininess negative above ~2000, so the conversion now uses specular and + // shininess directly. float dsr = 0.04; // dielectricSpecularReflectance float specularIntensity = 0.2125 * specular[0] + 0.7154 * specular[1] + 0.0721 * specular[2]; @@ -174,8 +182,18 @@ phongToPbr(const GfVec3f& diffuse, 0.587 * specular[1] * specular[1] + 0.114 * specular[2] * specular[2]; float specularStrength = std::max(specular[0], std::max(specular[1], specular[2])); + + // Roughness from Phong: alpha = sqrt(2 / (N_s + 2)), with specularIntensity accounting for + // colored speculars. roughness = sqrt(2 / (shininessFactor * shininess * specularIntensity + 2)); + // High Phong shininess (above ~600, which maps to roughness below MIRROR_THRESHOLD) describes + // a near-perfect mirror. Clamp the tiny residual to 0 so it renders as a clean mirror instead + // of a faintly rough surface. + if (roughness < MIRROR_THRESHOLD) { + roughness = 0.0; + } + float specComplement = 1 - specularStrength; float A = dsr; float B = (diffuseBrightness * (specComplement / (1 - A)) + specularBrightness) - 2 * A; @@ -184,6 +202,14 @@ phongToPbr(const GfVec3f& diffuse, float value = (-B + squareRoot) / (2 * A); metallic = std::min(1.0f, std::max(0.0f, value)); + // Note on Phong-to-PBR conversion ambiguity: + // A Phong material with black diffuse + colored specular (e.g., diffuse=(0,0,0), + // specular=(0,1,1)) is mathematically interpreted as a colored metal, resulting in metallic=1.0 + // and baseColor=specular. This is physically correct: metals have no diffuse component and + // their color comes from specular. However, legacy Phong materials sometimes used this pattern + // for artistic "black base + colored highlights" which would be a dielectric in PBR. Artists + // should adjust such materials post-import if needed. + float factor = specComplement / (1.0f - dsr) / std::max(1e-4, 1.0 - metallic); float dielectricR = diffuse[0] * factor; float dielectricG = diffuse[1] * factor; @@ -200,6 +226,22 @@ phongToPbr(const GfVec3f& diffuse, albedo[2] = std::min(1.0f, std::max(0.0f, albedo[2])); } +// Computes only the roughness component of a Phong-to-PBR conversion, without touching metallic. +// Used when the caller wants to preserve an explicit metallic/reflectionFactor value from the +// source material rather than letting the full Phong-to-PBR algorithm infer metallicness from +// specular brightness (which over-estimates metallic for car-paint and other high-specular +// dielectrics). +static float +phongRoughness(float shininess, const GfVec3f& specular, float shininessFactor) +{ + float specularIntensity = 0.2125f * specular[0] + 0.7154f * specular[1] + 0.0721f * specular[2]; + float roughness = sqrt(2.0f / (shininessFactor * shininess * specularIntensity + 2.0f)); + if (roughness < MIRROR_THRESHOLD) { + roughness = 0.0f; + } + return roughness; +} + bool bumpToNormal(const Image& bump, Image& normal, float multiplier) { @@ -225,6 +267,42 @@ bumpToNormal(const Image& bump, Image& normal, float multiplier) return true; } +float +singleScatterToMultiscatter(float singleScatter, float anisotropy) +{ + float s = std::sqrt((1.0f - singleScatter) / (1.0f - singleScatter * anisotropy)); + return (1.0f - s) * (1.0f - 0.139f * s) / (1.0f + 1.17f * s); +} + +GfVec3f +singleScatterToMultiscatter(const GfVec3f& singleScatter, float anisotropy) +{ + return GfVec3f(singleScatterToMultiscatter(singleScatter[0], anisotropy), + singleScatterToMultiscatter(singleScatter[1], anisotropy), + singleScatterToMultiscatter(singleScatter[2], anisotropy)); +} + +float +multiscatterToSingleScatter(float multiscatter, float anisotropy) +{ + multiscatter = std::clamp(multiscatter, 0.0f, 0.9999f); + anisotropy = std::clamp(anisotropy, -0.9999f, 0.9999f); + + const float s = 4.09712f + 4.20863f * multiscatter; + const float p = 9.59217f + 41.6808f * multiscatter + 17.7126f * multiscatter * multiscatter; + const float singleScatter = 1.0f - (s - std::sqrt(p)) * (s - std::sqrt(p)); + const float denom = std::max(1.0e-4f, 1.0f - anisotropy * multiscatter * multiscatter); + return std::clamp(singleScatter / denom, 0.0f, 1.0f); +} + +GfVec3f +multiscatterToSingleScatter(const GfVec3f& multiscatter, float anisotropy) +{ + return GfVec3f(multiscatterToSingleScatter(multiscatter[0], anisotropy), + multiscatterToSingleScatter(multiscatter[1], anisotropy), + multiscatterToSingleScatter(multiscatter[2], anisotropy)); +} + InputTranslator::InputTranslator(bool exportImages, std::vector& inputImages, const std::string& debugTag) @@ -262,8 +340,19 @@ InputTranslator::translateDirectInternal(int imageIdx, Input& out) ImageAsset& newAsset = mImagesDst.back(); newAsset.uri = key; newAsset.name = asset.name; - newAsset.format = asset.format; - newAsset.image = asset.image; // create a copy + if (asset.format == ImageFormatUnknown && imageIdx < (int)mDecodedMap.size() && + mDecodedMap[imageIdx]) { + // Intermediate image: decoded pixels exist but no encoded bytes (format is Unknown). + // Infer the encode format from the URI extension (e.g. a cache key ending in ".exr" + // should stay EXR); fall back to PNG if the extension is absent or unrecognised. + const ImageFormat inferredFormat = getFormat(TfStringGetSuffix(asset.uri)); + newAsset.format = + (inferredFormat != ImageFormatUnknown) ? inferredFormat : ImageFormatPng; + mDecodedImages[imageIdx].write(newAsset); + } else { + newAsset.format = asset.format; + newAsset.image = asset.image; // create a copy + } mCache[key] = imageIndex; } out.image = imageIndex; @@ -386,9 +475,9 @@ InputTranslator::translateFactor(const Input& in, // Both inputs are images // Storage format is determined by the in input const ImageAsset& inImageAsset = mImagesSrc[in.image]; - std::string key = "factor-" + std::to_string(in.image) + "-" + - std::to_string(factor.image) + "." + - getFormatExtension(inImageAsset.format); + std::string assetName = + "factor-" + std::to_string(in.image) + "-" + std::to_string(factor.image); + std::string key = assetName + "." + getFormatExtension(inImageAsset.format); int imageIndex = -1; const auto& it = mCache.find(key); if (it != mCache.end()) { @@ -401,7 +490,8 @@ InputTranslator::translateFactor(const Input& in, GUARD(inImageValid && factorImageValid, "Invalid images"); imageMult(inImage, factorImage, outImage); } - imageIndex = addImage(std::move(outImage), key, inImageAsset.format, intermediate); + imageIndex = + addImage(std::move(outImage), assetName, key, inImageAsset.format, intermediate); } // Copy the input image's settings and update to the new image index out = in; @@ -459,6 +549,671 @@ InputTranslator::translateFactor(const Input& in, return true; } +namespace { + +int +_getInputComponentCount(const Input& input) +{ + if (input.image >= 0) { + if (input.channel == AdobeTokens->rgba) { + return 4; + } + if (input.channel == AdobeTokens->rgb) { + return 3; + } + return 1; + } + if (input.value.IsHolding() || input.value.IsHolding()) { + return 1; + } + if (input.value.IsHolding()) { + return 2; + } + if (input.value.IsHolding()) { + return 3; + } + if (input.value.IsHolding()) { + return 4; + } + return 0; +} + +void +_getConstantInputValues(const Input& input, int outChannels, float* values) +{ + for (int i = 0; i < outChannels; ++i) { + values[i] = 0.0f; + } + + float f; + GfVec2f v2; + GfVec3f v3; + GfVec4f v4; + + if (getInputValue(input, &f)) { + for (int i = 0; i < outChannels; ++i) { + values[i] = f; + } + } else if (getInputValue(input, &v2)) { + for (int i = 0; i < std::min(outChannels, 2); ++i) { + values[i] = v2[i]; + } + for (int i = 2; i < outChannels; ++i) { + values[i] = values[0]; + } + } else if (getInputValue(input, &v3)) { + for (int i = 0; i < std::min(outChannels, 3); ++i) { + values[i] = v3[i]; + } + if (outChannels == 4) { + values[3] = input.scale[3] + input.bias[3]; + } + } else if (getInputValue(input, &v4)) { + for (int i = 0; i < outChannels; ++i) { + values[i] = v4[i]; + } + } +} + +// Samples an input image at normalized UV coordinates using bilinear filtering. +bool +_sampleInputImageBilinear(const Input& input, + const Image& image, + float u, + float v, + int outChannels, + float* values) +{ + const int w = image.width; + const int h = image.height; + const int ch = image.channels; + + // Map UV to pixel space with pixel centers at x+0.5. Clamp UV to [0,1] first so that + // floor() gives consistent x0/y0 and the fractional parts tx/ty stay in [0,1). + const float px = std::max(0.0f, std::min(1.0f, u)) * w - 0.5f; + const float py = std::max(0.0f, std::min(1.0f, v)) * h - 0.5f; + const float fpx = std::floor(px); + const float fpy = std::floor(py); + const int x0 = std::max(0, static_cast(fpx)); + const int y0 = std::max(0, static_cast(fpy)); + const int x1 = std::min(w - 1, x0 + 1); + const int y1 = std::min(h - 1, y0 + 1); + const float tx = px - fpx; + const float ty = py - fpy; + + // Per-corner base pointers — the (y*width + x)*channels offset is constant per corner; + // adding the channel index c gives the exact element without per-iteration arithmetic. + // p00 is always needed; p10/p01/p11 are only needed for bilinear interpolation. + const float* const pixels = image.pixels.data(); + const float* const p00 = pixels + (y0 * w + x0) * ch; + + // Exact-pixel fast path: when both fractional parts are zero the UV lands directly on + // pixel (x0, y0) — no interpolation is needed and the three other corners are never + // touched. This is always the case when the source image and the output image share + // the same dimensions (pixel-centre UVs map back to exact integers after the round-trip). + if (tx == 0.0f && ty == 0.0f) { + if (input.channel == AdobeTokens->rgb || input.channel == AdobeTokens->rgba) { + const int srcChannels = std::min(outChannels, ch); + for (int c = 0; c < srcChannels; ++c) { + values[c] = p00[c] * input.scale[c] + input.bias[c]; + } + if (outChannels > ch) { + const float lastVal = p00[ch - 1]; + const int broadcastEnd = std::min(outChannels, 3); + for (int c = ch; c < broadcastEnd; ++c) { + values[c] = lastVal * input.scale[c] + input.bias[c]; + } + if (outChannels == 4) { + values[3] = 1.0f; + } + } + return true; + } + int srcChannel = token2Channel(input.channel); + if (srcChannel < 0) { + return false; + } + if (srcChannel >= ch) { + if (ch == 1) { + srcChannel = 0; + } else { + TF_WARN("Input channel %s out of range for source image with %d channels", + input.channel.GetText(), + ch); + return false; + } + } + const float val = p00[srcChannel] * input.scale[0] + input.bias[0]; + values[0] = val; + for (int c = 1; c < outChannels; ++c) { + values[c] = (c == 3) ? 1.0f : val; + } + return true; + } + + // Bilinear path — compute the three remaining corner pointers and the four weights. + const float* const p10 = pixels + (y0 * w + x1) * ch; + const float* const p01 = pixels + (y1 * w + x0) * ch; + const float* const p11 = pixels + (y1 * w + x1) * ch; + + const float w00 = (1.0f - tx) * (1.0f - ty); + const float w10 = tx * (1.0f - ty); + const float w01 = (1.0f - tx) * ty; + const float w11 = tx * ty; + + if (input.channel == AdobeTokens->rgb || input.channel == AdobeTokens->rgba) { + if (outChannels == ch) { + // Fast path: source and output channel counts match — no broadcasting or alpha + // synthesis needed. The inner loop is branch-free. + for (int c = 0; c < outChannels; ++c) { + const float val = w00 * p00[c] + w10 * p10[c] + w01 * p01[c] + w11 * p11[c]; + values[c] = val * input.scale[c] + input.bias[c]; + } + return true; + } + + // Slow path: output channel count differs from the source. + // Region 1: channels that exist in the source — direct indexed, no clamping. + const int srcChannels = std::min(outChannels, ch); + for (int c = 0; c < srcChannels; ++c) { + const float val = w00 * p00[c] + w10 * p10[c] + w01 * p01[c] + w11 * p11[c]; + values[c] = val * input.scale[c] + input.bias[c]; + } + if (outChannels > ch) { + // Region 2: broadcast the last source channel into any non-alpha slots. + // The bilinear value is the same for every broadcast channel, so compute it once. + const float lastVal = + w00 * p00[ch - 1] + w10 * p10[ch - 1] + w01 * p01[ch - 1] + w11 * p11[ch - 1]; + const int broadcastEnd = std::min(outChannels, 3); + for (int c = ch; c < broadcastEnd; ++c) { + values[c] = lastVal * input.scale[c] + input.bias[c]; + } + // Region 3: synthesize alpha = 1.0 when the source has no alpha channel. + if (outChannels == 4) { + values[3] = 1.0f; + } + } + return true; + } + + int srcChannel = token2Channel(input.channel); + if (srcChannel < 0) { + return false; + } + if (srcChannel >= ch) { + if (ch == 1) { + srcChannel = 0; + } else { + TF_WARN("Input channel %s out of range for source image with %d channels", + input.channel.GetText(), + ch); + return false; + } + } + const float val = + w00 * p00[srcChannel] + w10 * p10[srcChannel] + w01 * p01[srcChannel] + w11 * p11[srcChannel]; + values[0] = val * input.scale[0] + input.bias[0]; + for (int c = 1; c < outChannels; ++c) { + // Synthetic alpha (channel 3 when the source is single-channel) should be 1.0, + // not a copy of the sampled scalar value. + values[c] = (c == 3) ? 1.0f : values[0]; + } + return true; +} + +void +_setTranslatedInputImageDefaults(const Input& reference, int channels, Input& out) +{ + out = reference; + out.channel = channels == 1 ? AdobeTokens->r + : channels == 4 ? AdobeTokens->rgba + : AdobeTokens->rgb; + out.scale = kDefaultTexScale; + out.bias = kDefaultTexBias; +} + +void +_setTranslatedInputConstant(const std::vector& values, Input& out) +{ + if (values.size() == 1) { + out.value = values[0]; + } else if (values.size() == 2) { + out.value = GfVec2f(values[0], values[1]); + } else if (values.size() == 3) { + out.value = GfVec3f(values[0], values[1], values[2]); + } else if (values.size() == 4) { + out.value = GfVec4f(values[0], values[1], values[2], values[3]); + } + out.image = -1; + out.scale = kDefaultTexScale; + out.bias = kDefaultTexBias; +} + +} + +template +bool +_valuesAreEqual(const std::vector& values) +{ + if (values.empty()) { + return true; + } + T firstValue = values[0]; + for (size_t i = 1; i < values.size(); ++i) { + if (firstValue != values[i]) + return false; + } + return true; +} + +// Checks that all image inputs share the same UV transform (uvIndex, uvRotation, uvScale, +// uvTranslation). +// - When all match: copies the common values onto \p out and returns true. +// - When any differ: resets \p out's UV transform to identity and returns false, indicating the +// caller should bake each input's transform into the pixel data instead. +// Inputs with no image (image < 0) are skipped. +bool +_resolveOutputUVTransform(const std::vector& inputs, Input& out) +{ + std::vector uvIndices; + std::vector rotations; + std::vector scales; + std::vector translations; + for (const Input* input : inputs) { + if (input && input->image >= 0) { + uvIndices.push_back(input->uvIndex); + rotations.push_back(input->uvRotation); + scales.push_back(input->uvScale); + translations.push_back(input->uvTranslation); + } + } + const bool match = _valuesAreEqual(uvIndices) && _valuesAreEqual(rotations) && + _valuesAreEqual(scales) && _valuesAreEqual(translations); + if (match) { + if (!uvIndices.empty()) { + out.uvIndex = uvIndices[0]; + out.uvRotation = rotations[0]; + out.uvScale = scales[0]; + out.uvTranslation = translations[0]; + } + } else { + // Transforms will be baked into pixel data; output has identity UV transform. + out.uvIndex = 0; + out.uvRotation = kDefaultUvRotation; + out.uvScale = kDefaultUvScale; + out.uvTranslation = kDefaultUvTranslation; + } + return match; +} + +// Applies an input's UV transform (scale, rotation, translation) to the normalized coordinates +// Precomputed 2×3 affine matrix for a UV transform. Built once per input outside the pixel +// loop so that cos/sin and the constant folding are not repeated per pixel. +struct UVTransform +{ + float a, b, c; // su = a*u + b*v + c + float d, e, f; // sv = d*u + e*v + f + bool isIdentity; +}; + +// Build a UVTransform from an Input's uvRotation / uvScale / uvTranslation. +// Expands the place2d convention (rotate around (0.5,0.5), then scale, then translate) into +// the equivalent 2×3 matrix so that per-pixel work is just two multiply-adds. +UVTransform +_buildUVTransform(const Input& input) +{ + if (input.hasDefaultTransform()) { + return { 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, true }; + } + const float r = input.uvRotation * (static_cast(M_PI) / 180.0f); + const float cosR = std::cos(r); + const float sinR = std::sin(r); + const float sx = input.uvScale[0]; + const float sy = input.uvScale[1]; + const float tx = input.uvTranslation[0]; + const float ty = input.uvTranslation[1]; + return { cosR * sx, -sinR * sx, 0.5f * sx * (1.0f - cosR + sinR) + tx, + sinR * sy, cosR * sy, 0.5f * sy * (1.0f - sinR - cosR) + ty, + false }; +} + +// (u, v) and returns the resulting source UV, wrapped to [0, 1) with repeat semantics. +// Call _buildUVTransform once per input outside the pixel loop and pass the result here. +void +_applyUVTransform(const UVTransform& xf, float u, float v, float& su, float& sv) +{ + if (xf.isIdentity) { + su = u; + sv = v; + return; + } + su = xf.a * u + xf.b * v + xf.c; + sv = xf.d * u + xf.e * v + xf.f; + su -= std::floor(su); + sv -= std::floor(sv); +} + +// Returns true if all image inputs share the same UV transform. Constant inputs (image < 0) +// are ignored. Used to decide whether to bake transforms into pixel data or propagate as metadata. +bool +_inputsShareUVTransform(const std::vector& inputs) +{ + std::vector uvIndices; + std::vector rotations; + std::vector scales; + std::vector translations; + for (const Input* input : inputs) { + if (input && input->image >= 0) { + uvIndices.push_back(input->uvIndex); + rotations.push_back(input->uvRotation); + scales.push_back(input->uvScale); + translations.push_back(input->uvTranslation); + } + } + return _valuesAreEqual(uvIndices) && _valuesAreEqual(rotations) && _valuesAreEqual(scales) && + _valuesAreEqual(translations); +} + +// Produces a cache-key fragment encoding the UV transform of an image input, for use when the +// transform is being baked into pixel data rather than propagated as output metadata. +// Returns an empty string for constant inputs (image < 0) or default transforms. +std::string +_uvTransformKey(const Input& input) +{ + if (input.image < 0 || input.hasDefaultTransform()) { + return ""; + } + return TfStringPrintf("-uv%d_r%.4f_s%.4f,%.4f_t%.4f,%.4f", + input.uvIndex, + input.uvRotation, + input.uvScale[0], + input.uvScale[1], + input.uvTranslation[0], + input.uvTranslation[1]); +} + +// --------------------------------------------------------------------------- +// _applyImageOp — shared implementation for translateProduct / translateMax / translateLerp +// --------------------------------------------------------------------------- +// All three functions share the same structure: cache-key construction, image decode, output-size +// computation, UV-transform building, and the per-pixel sample loop. Only the per-pixel math +// differs; that is supplied by the caller as a small lambda (PixelFn). +// +// PixelFn signature: void(float* const* bufs, float* outPixel, int outChannels) +// bufs[i] points to the slot.channels float values that were sampled (or evaluated as a +// constant) for slot i. The function must write exactly outChannels floats to outPixel. + +template +bool +InputTranslator::_applyImageOp(const std::string& name, + const std::string& opTag, + const std::string& extraKeySuffix, + std::initializer_list<_ImageOpSlot> slots, + int outChannels, + bool intermediate, + Input& out, + PixelFn pixelFn) +{ + const std::vector<_ImageOpSlot> slotsVec(slots); + const int slotCount = static_cast(slotsVec.size()); + + // Collect raw input pointers for UV-transform helpers. + std::vector inputPtrs; + inputPtrs.reserve(slotCount); + for (const auto& s : slotsVec) + inputPtrs.push_back(s.input); + + // Find the first image-bearing input; it supplies the output UV metadata. + const Input* reference = nullptr; + for (const Input* p : inputPtrs) { + if (p->image >= 0) { + reference = p; + break; + } + } + GUARD(reference && getDecodedImage(reference->image).first, "Invalid reference image"); + + // When UV transforms differ across image inputs, each input's transform is baked into the + // pixel data; the cache key must then include the transform parameters to avoid collisions. + const bool transformsMatch = _inputsShareUVTransform(inputPtrs); + + // Build cache key: name-opTag-key0-key1-…[uvKeys][extraSuffix].png + std::string key = name + "-" + opTag + "-"; + for (int s = 0; s < slotCount; ++s) { + if (s > 0) + key += "-"; + key += input2key(slotsVec[s].input->image, slotsVec[s].input->channel, 0); + } + if (!transformsMatch) { + for (const auto& slot : slotsVec) + key += _uvTransformKey(*slot.input); + } + key += extraKeySuffix + ".png"; + + int imageIndex = -1; + const auto it = mCache.find(key); + if (it != mCache.end()) { + imageIndex = it->second; + } else { + Image outImage; + if (mExportImages) { + // Per-slot decode state. + struct SlotState + { + Image image; + UVTransform xf = {}; + }; + std::vector state(slotCount); + + // Decode each image-bearing slot. + for (int s = 0; s < slotCount; ++s) { + if (slotsVec[s].input->image >= 0) { + auto [ok, img] = getDecodedImage(slotsVec[s].input->image); + if (!ok) { + TF_WARN("Failed to decode image for '%s'", name.c_str()); + return false; + } + state[s].image = img; + } + } + + // Output resolution = union of all contributing image dimensions. + int outW = 0, outH = 0; + for (int s = 0; s < slotCount; ++s) { + if (slotsVec[s].input->image >= 0) { + outW = std::max(outW, state[s].image.width); + outH = std::max(outH, state[s].image.height); + } + } + outImage.allocate(outW, outH, outChannels); + + // Build per-slot affine UV transforms once, outside the pixel loop. + for (int s = 0; s < slotCount; ++s) + state[s].xf = _buildUVTransform(transformsMatch ? Input{} : *slotsVec[s].input); + + // Allocate per-slot sample buffers. + std::vector> bufs(slotCount); + std::vector bufPtrs(slotCount); + for (int s = 0; s < slotCount; ++s) { + bufs[s].resize(slotsVec[s].channels); + bufPtrs[s] = bufs[s].data(); + } + + const int pixelCount = outW * outH; + for (int i = 0; i < pixelCount; ++i) { + const float u = (i % outW + 0.5f) / outW; + const float v = (i / outW + 0.5f) / outH; + float su, sv; + for (int s = 0; s < slotCount; ++s) { + const _ImageOpSlot& slot = slotsVec[s]; + float* buf = bufPtrs[s]; + if (slot.input->image >= 0) { + _applyUVTransform(state[s].xf, u, v, su, sv); + if (!_sampleInputImageBilinear( + *slot.input, state[s].image, su, sv, slot.channels, buf)) { + TF_WARN("Failed to sample image for '%s'", name.c_str()); + return false; + } + if (slot.linearize) { + for (int c = 0; c < slot.channels; ++c) + buf[c] = srgbToLinear(buf[c]); + } + } else { + _getConstantInputValues(*slot.input, slot.channels, buf); + } + } + pixelFn(bufPtrs.data(), outImage.pixels.data() + i * outChannels, outChannels); + } + } + imageIndex = addImage(std::move(outImage), name, key, ImageFormatPng, intermediate); + mCache[key] = imageIndex; + } + + _setTranslatedInputImageDefaults(*reference, outChannels, out); + _resolveOutputUVTransform(inputPtrs, out); + out.image = imageIndex; + return true; +} + +bool +InputTranslator::translateProduct(const std::string& name, + const Input& in, + const Input& factor, + Input& out, + bool intermediate, + bool linearize) +{ + if (in.isEmpty() || factor.isEmpty()) + return false; + + const int outChannels = std::max(_getInputComponentCount(in), _getInputComponentCount(factor)); + if (outChannels <= 0) + return false; + + if (in.image < 0 && factor.image < 0) { + std::vector lhs(outChannels), rhs(outChannels); + _getConstantInputValues(in, outChannels, lhs.data()); + _getConstantInputValues(factor, outChannels, rhs.data()); + for (int i = 0; i < outChannels; ++i) + lhs[i] *= rhs[i]; + out = in; + _setTranslatedInputConstant(lhs, out); + return true; + } + + return _applyImageOp(name, + "product", + linearize ? "-lin" : "", + { { &in, outChannels, linearize }, { &factor, outChannels, linearize } }, + outChannels, + intermediate, + out, + [linearize](float* const* b, float* dst, int ch) { + for (int c = 0; c < ch; ++c) { + const float val = b[0][c] * b[1][c]; + dst[c] = linearize ? linearToSRGB(val) : val; + } + }); +} + +bool +InputTranslator::translateMax(const std::string& name, + const Input& in0, + const Input& in1, + Input& out, + bool intermediate) +{ + if (in0.isEmpty() || in1.isEmpty()) { + return false; + } + + const int outChannels = std::max(_getInputComponentCount(in0), _getInputComponentCount(in1)); + if (outChannels <= 0) { + return false; + } + + if (in0.image < 0 && in1.image < 0) { + std::vector lhs(outChannels); + std::vector rhs(outChannels); + _getConstantInputValues(in0, outChannels, lhs.data()); + _getConstantInputValues(in1, outChannels, rhs.data()); + for (int i = 0; i < outChannels; ++i) { + lhs[i] = std::max(lhs[i], rhs[i]); + } + out = in0; + _setTranslatedInputConstant(lhs, out); + return true; + } + + return _applyImageOp(name, + "max", + "", + { { &in0, outChannels, false }, { &in1, outChannels, false } }, + outChannels, + intermediate, + out, + [](float* const* b, float* dst, int ch) { + for (int c = 0; c < ch; ++c) + dst[c] = std::max(b[0][c], b[1][c]); + }); +} + +bool +InputTranslator::translateLerp(const std::string& name, + const Input& in0, + const Input& in1, + const Input& mask, + Input& out, + bool intermediate, + bool linearize) +{ + if (mask.isEmpty()) + return translateDirect(in0, out, intermediate); + if (in0.isEmpty()) + return translateDirect(in1, out, intermediate); + if (in1.isEmpty()) + return translateDirect(in0, out, intermediate); + if (mask.numChannels() != 1) { + TF_WARN("translateLerp expects a single-channel mask input"); + return false; + } + + const int outChannels = std::max(_getInputComponentCount(in0), _getInputComponentCount(in1)); + if (outChannels <= 0) + return false; + + if (in0.image < 0 && in1.image < 0 && mask.image < 0) { + std::vector lhs(outChannels), rhs(outChannels), tvals(1); + _getConstantInputValues(in0, outChannels, lhs.data()); + _getConstantInputValues(in1, outChannels, rhs.data()); + _getConstantInputValues(mask, 1, tvals.data()); + const float t = std::clamp(tvals[0], 0.0f, 1.0f); + for (int i = 0; i < outChannels; ++i) + lhs[i] = lhs[i] * (1.0f - t) + rhs[i] * t; + out = in0; + _setTranslatedInputConstant(lhs, out); + return true; + } + + // The mask drives blending weight; include it in the transform check so that a mask on a + // different UV set than in0/in1 also triggers per-input baking. + return _applyImageOp( + name, + "lerp", + linearize ? "-lin" : "", + { { &in0, outChannels, linearize }, { &in1, outChannels, linearize }, { &mask, 1, false } }, + outChannels, + intermediate, + out, + [linearize](float* const* b, float* dst, int ch) { + const float t = std::clamp(b[2][0], 0.0f, 1.0f); + for (int c = 0; c < ch; ++c) { + const float val = b[0][c] * (1.0f - t) + b[1][c] * t; + dst[c] = linearize ? linearToSRGB(val) : val; + } + }); +} + // TODO: complete testing of translateAffine on images // TODO: complete testing of 'intermediate' capability bool @@ -485,8 +1240,8 @@ InputTranslator::extractChannel(const std::string& name, if (in.image >= 0) { const ImageAsset& inImageAsset = mImagesSrc[in.image]; - std::string key = name + "-" + input2key(in.image, channelIndex) + "." + - getFormatExtension(inImageAsset.format); + std::string assetName = name + "-" + input2key(in.image, channelIndex); + std::string key = assetName + "." + getFormatExtension(inImageAsset.format); int texture = -1; const auto& it = mCache.find(key); if (it != mCache.end()) { @@ -512,7 +1267,7 @@ InputTranslator::extractChannel(const std::string& name, imageExtractChannel(inImage, channelIndex, newscale, newbias, outImage); } } - texture = addImage(std::move(outImage), key, inImageAsset.format, false); + texture = addImage(std::move(outImage), assetName, key, inImageAsset.format, false); } out.image = texture; out.channel = AdobeTokens->r; @@ -551,8 +1306,8 @@ InputTranslator::translateAffine(const std::string& name, out = in; if (in.image >= 0) { const ImageAsset& inImageAsset = mImagesSrc[in.image]; - std::string key = - name + "-" + std::to_string(in.image) + "." + getFormatExtension(inImageAsset.format); + std::string assetName = name + "-" + std::to_string(in.image); + std::string key = assetName + "." + getFormatExtension(inImageAsset.format); int texture = -1; const auto& it = mCache.find(key); if (it != mCache.end()) { @@ -564,7 +1319,8 @@ InputTranslator::translateAffine(const std::string& name, GUARD(inImageValid, "Invalid image"); imageTransformAffine(inImage, scale, bias, outImage); } - texture = addImage(std::move(outImage), key, inImageAsset.format, intermediate); + texture = + addImage(std::move(outImage), assetName, key, inImageAsset.format, intermediate); } out.image = texture; } @@ -628,19 +1384,20 @@ InputTranslator::translatePhong2PBR(const Input& diffuseIn, Image metallic; if (mExportImages) { - // Whether textures exist or not, first attempt to decode what we can. - const ImageAsset& diffAsset = - diffuseIn.image != -1 ? mImagesSrc[diffuseIn.image] : ImageAsset(); - const ImageAsset& specAsset = - specularIn.image != -1 ? mImagesSrc[specularIn.image] : ImageAsset(); - const ImageAsset& glossAsset = - glosinessIn.image != -1 ? mImagesSrc[glosinessIn.image] : ImageAsset(); Image diffuse; Image specular; Image shininess; - GUARD(diffuse.read(diffAsset, 3), "Invalid diffuse image"); - GUARD(specular.read(specAsset, 3), "Invalid specular image"); - GUARD(shininess.read(glossAsset, 1), "Invalid gloss image"); + // Only decode source textures that actually exist. An absent source + // (image == -1) leaves its component empty; the empty-component handling + // below substitutes a sensible default. A present but corrupt or over-sized + // source still fails read() and aborts, preserving the dimension/overflow + // guards. + if (diffuseIn.image != -1) + GUARD(diffuse.read(mImagesSrc[diffuseIn.image], 3), "Invalid diffuse image"); + if (specularIn.image != -1) + GUARD(specular.read(mImagesSrc[specularIn.image], 3), "Invalid specular image"); + if (glosinessIn.image != -1) + GUARD(shininess.read(mImagesSrc[glosinessIn.image], 1), "Invalid gloss image"); // We need to regularize dimensions. Diffuse component has priority. int width = diffuse.width; @@ -759,6 +1516,34 @@ InputTranslator::translatePhong2PBR(const Input& diffuseIn, return true; } +bool +InputTranslator::translatePhong2Roughness(const Input& specularIn, + const Input& shininessIn, + Input& roughnessOut) +{ + if (!specularIn.value.IsEmpty() && !specularIn.value.IsHolding()) + return false; + if (!shininessIn.value.IsEmpty() && !shininessIn.value.IsHolding()) + return false; + + // Texture-based path: fall back to full phong-to-PBR image bake, but discard the metallic + // and diffuse outputs — we only keep the roughness texture. + if (specularIn.image >= 0 || shininessIn.image >= 0) { + Input unusedDiffuse; + Input unusedMetallic; + return translatePhong2PBR( + Input{}, specularIn, shininessIn, unusedDiffuse, unusedMetallic, roughnessOut); + } + + // Value-based path: compute roughness directly without the full metallic solve. + GfVec3f specularValue = + !specularIn.value.IsEmpty() ? specularIn.value.Get() : GfVec3f(0.5f); + float shininessValue = + shininessIn.value.IsHolding() ? shininessIn.value.UncheckedGet() : 0.5f; + roughnessOut.value = phongRoughness(shininessValue, specularValue, 1); + return true; +} + bool InputTranslator::translateNormals(const Input& bumpIn, const Input& normalsIn, Input& normalsOut) { @@ -792,8 +1577,8 @@ InputTranslator::translateNormals(const Input& bumpIn, const Input& normalsIn, I normalsOut.wrapT = AdobeTokens->repeat; } normalsOut.colorspace = AdobeTokens->raw; - normalsOut.scale = GfVec4f(2); - normalsOut.bias = GfVec4f(-1); + normalsOut.scale = kOpenGLNormalTexScale; + normalsOut.bias = kOpenGLNormalTexBias; return true; } @@ -874,19 +1659,174 @@ _collect2DTransformValues(const Input& input, } } -template bool -_valuesAreEqual(const std::vector& values) +InputTranslator::translateMultiscatterToSingleScatter(const std::string& name, + const Input& in, + float anisotropy, + Input& out, + bool intermediate) { - if (values.empty()) { + out = in; + if (intermediate) { return true; } - T firstValue = values[0]; - for (size_t i = 1; i < values.size(); ++i) { - if (firstValue != values[i]) - return false; + + if (in.image >= 0) { + // The scale components represent the multiscatterColorFactor (per-channel tint). + // Because the multiscatter→single-scatter conversion is nonlinear, the factor must be + // applied per-texel before the conversion rather than carried along as metadata. + // Include the scale in the cache key so that different factors produce distinct images. + std::string key = + name + "-" + input2key(in.image, in.channel, 0) + "-singlescatter-" + + TfStringPrintf("%.4f-%.4f-%.4f-%.4f", in.scale[0], in.scale[1], in.scale[2], anisotropy) + + ".png"; + int texture = -1; + const auto it = mCache.find(key); + if (it != mCache.end()) { + texture = it->second; + } else { + Image outImage; + if (mExportImages) { + auto [inImageValid, inImage] = getDecodedImage(in.image); + GUARD(inImageValid, "Invalid image"); + const int outputChannels = inImage.channels >= 4 ? 4 : 3; + if (!outImage.allocate(inImage.width, inImage.height, outputChannels)) { + TF_WARN("Failed to allocate output image for %s", key.c_str()); + return false; + } + const int pixelCount = inImage.width * inImage.height; + for (int i = 0; i < pixelCount; ++i) { + const int inIdx = i * inImage.channels; + const int outIdx = i * outputChannels; + // Apply the multiscatterColorFactor to each channel before converting to + // single-scatter albedo. Clamp to [0,1] since the formula requires it. + const float rawR = inImage.pixels[inIdx + 0]; + const float rawG = inImage.channels >= 3 ? inImage.pixels[inIdx + 1] : rawR; + const float rawB = inImage.channels >= 3 ? inImage.pixels[inIdx + 2] : rawR; + const float r = std::clamp(rawR * in.scale[0], 0.0f, 1.0f); + const float g = std::clamp(rawG * in.scale[1], 0.0f, 1.0f); + const float b = std::clamp(rawB * in.scale[2], 0.0f, 1.0f); + outImage.pixels[outIdx + 0] = multiscatterToSingleScatter(r, anisotropy); + outImage.pixels[outIdx + 1] = multiscatterToSingleScatter(g, anisotropy); + outImage.pixels[outIdx + 2] = multiscatterToSingleScatter(b, anisotropy); + if (outputChannels == 4) { + outImage.pixels[outIdx + 3] = + inImage.channels >= 4 ? inImage.pixels[inIdx + 3] : 1.0f; + } + } + } + texture = addImage(std::move(outImage), key, key, ImageFormatPng, false); + mCache[key] = texture; + } + out.image = texture; + // The factor has been consumed into the pixel values; reset to default (white) so + // it is not re-emitted as a separate multiscatterColorFactor on export. + out.scale = GfVec4f(1.0f); + return true; } - return true; + + if (in.value.IsHolding()) { + out.value = multiscatterToSingleScatter(in.value.UncheckedGet(), anisotropy); + return true; + } else if (in.value.IsHolding()) { + const GfVec3f multiscatter = in.value.UncheckedGet(); + out.value = GfVec3f(multiscatterToSingleScatter(multiscatter[0], anisotropy), + multiscatterToSingleScatter(multiscatter[1], anisotropy), + multiscatterToSingleScatter(multiscatter[2], anisotropy)); + return true; + } + + return !in.value.IsEmpty(); +} + +bool +InputTranslator::translateSingleScatterToMultiscatter(const std::string& name, + const Input& in, + float anisotropy, + Input& out, + bool intermediate) +{ + out = in; + if (intermediate) { + return true; + } + + if (in.image >= 0) { + // The scale components represent the single-scatter albedo factor. Because the + // single-scatter→multiscatter conversion is nonlinear, the factor must be applied + // per-texel before the conversion rather than carried along as metadata. + // Include the scale in the cache key so that different factors produce distinct images. + std::string key = + name + "-" + input2key(in.image, in.channel, 0) + "-multiscatter-" + + TfStringPrintf("%.4f-%.4f-%.4f-%.4f", in.scale[0], in.scale[1], in.scale[2], anisotropy) + + ".png"; + int texture = -1; + const auto it = mCache.find(key); + if (it != mCache.end()) { + texture = it->second; + } else { + Image outImage; + if (mExportImages) { + auto [inImageValid, inImage] = getDecodedImage(in.image); + GUARD(inImageValid, "Invalid image"); + const int outputChannels = inImage.channels >= 4 ? 4 : 3; + if (!outImage.allocate(inImage.width, inImage.height, outputChannels)) { + TF_WARN("Failed to allocate output image for %s", key.c_str()); + return false; + } + const int pixelCount = inImage.width * inImage.height; + for (int i = 0; i < pixelCount; ++i) { + const int inIdx = i * inImage.channels; + const int outIdx = i * outputChannels; + // Apply the single-scatter factor to each channel before converting to + // multiscatter albedo. Clamp to [0,1] since the formula requires it. + const float rawR = inImage.pixels[inIdx + 0]; + const float rawG = inImage.channels >= 3 ? inImage.pixels[inIdx + 1] : rawR; + const float rawB = inImage.channels >= 3 ? inImage.pixels[inIdx + 2] : rawR; + const float r = std::clamp(rawR * in.scale[0], 0.0f, 1.0f); + const float g = std::clamp(rawG * in.scale[1], 0.0f, 1.0f); + const float b = std::clamp(rawB * in.scale[2], 0.0f, 1.0f); + outImage.pixels[outIdx + 0] = singleScatterToMultiscatter(r, anisotropy); + outImage.pixels[outIdx + 1] = singleScatterToMultiscatter(g, anisotropy); + outImage.pixels[outIdx + 2] = singleScatterToMultiscatter(b, anisotropy); + if (outputChannels == 4) { + outImage.pixels[outIdx + 3] = + inImage.channels >= 4 ? inImage.pixels[inIdx + 3] : 1.0f; + } + } + } + // Before adding an intermediate image to mImagesSrc, ensure mDecodedImages is + // sized to match so the new entry lands at the correct index. + while (mDecodedImages.size() < mImagesSrc.size()) { + mDecodedImages.push_back(Image()); + mDecodedMap.push_back(false); + } + // Store as intermediate (mImagesSrc) so that the caller's translateDirect step + // correctly encodes and references this image via translateDirectInternal. + // Using intermediate=false would put it in mImagesDst with an index that + // translateDirect would then misinterpret as a mImagesSrc index. + texture = addImage(std::move(outImage), key, key, ImageFormatPng, true); + mCache[key] = texture; + } + out.image = texture; + // The factor has been consumed into the pixel values; reset to default (white) so + // it is not re-emitted as a separate multiscatterColorFactor on export. + out.scale = GfVec4f(1.0f); + return true; + } + + if (in.value.IsHolding()) { + out.value = singleScatterToMultiscatter(in.value.UncheckedGet(), anisotropy); + return true; + } else if (in.value.IsHolding()) { + const GfVec3f singleScatter = in.value.UncheckedGet(); + out.value = GfVec3f(singleScatterToMultiscatter(singleScatter[0], anisotropy), + singleScatterToMultiscatter(singleScatter[1], anisotropy), + singleScatterToMultiscatter(singleScatter[2], anisotropy)); + return true; + } + + return !in.value.IsEmpty(); } bool @@ -1101,6 +2041,11 @@ InputTranslator::computeRange(const Input& input) std::pair InputTranslator::getDecodedImage(int index) { + static Image defaultImage; + if (index < 0 || index >= (int)mDecodedMap.size()) { + TF_WARN("Invalid image index: %d", index); + return { false, defaultImage }; + } if (mDecodedMap[index]) { return { true, mDecodedImages[index] }; } else { @@ -1116,6 +2061,7 @@ InputTranslator::getDecodedImage(int index) int InputTranslator::addImage(Image&& image, const std::string& assetName, + const std::string& assetUri, ImageFormat format, bool intermediate) { @@ -1137,7 +2083,7 @@ InputTranslator::addImage(Image&& image, } else { ImageAsset imageAsset; imageAsset.name = assetName; - imageAsset.uri = assetName; + imageAsset.uri = assetUri; // Note, the format of the image asset needs to be set, otherwise the writing/encoding // will not work imageAsset.format = format; @@ -1154,4 +2100,4 @@ InputTranslator::addImage(ImageAsset&& image) return texture; } -} \ No newline at end of file +} diff --git a/utils/src/naming.cpp b/utils/src/naming.cpp new file mode 100644 index 00000000..b0c07540 --- /dev/null +++ b/utils/src/naming.cpp @@ -0,0 +1,107 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +#include + +#include + +#include + +using namespace PXR_NS; + +namespace adobe::usd { + +namespace { + +// Encode a single TfUtf8CodePoint to UTF-8 bytes appended to `out`. +// USD does not ship a public encoder; this implements the standard UTF-8 +// encoding rules. Retire when Pixar's tf_utf8_identifiers proposal lands an +// upstream equivalent. (File-local `_lowerCamel` per the utils/src convention, +// e.g. _writeMetadata / _readNormalScale.) +// +// Invariant: TfUtf8CodePointView only yields scalar values <= U+10FFFF +// (malformed input decodes to U+FFFD), so the four-byte branch is the widest +// case and never reads or writes out of range. +void +_appendUtf8CodePoint(std::string& out, const TfUtf8CodePoint codePoint) +{ + const uint32_t c = codePoint.AsUInt32(); + if (c < 0x80) { + out += static_cast(c); + } else if (c < 0x800) { + out += static_cast(0xC0 | (c >> 6)); + out += static_cast(0x80 | (c & 0x3F)); + } else if (c < 0x10000) { + out += static_cast(0xE0 | (c >> 12)); + out += static_cast(0x80 | ((c >> 6) & 0x3F)); + out += static_cast(0x80 | (c & 0x3F)); + } else { + out += static_cast(0xF0 | (c >> 18)); + out += static_cast(0x80 | ((c >> 12) & 0x3F)); + out += static_cast(0x80 | ((c >> 6) & 0x3F)); + out += static_cast(0x80 | (c & 0x3F)); + } +} + +} // anonymous namespace + +std::string +MakeValidUsdIdentifier(const std::string& source) +{ + if (source.empty()) { + return "_"; + } + + std::string result; + result.reserve(source.size()); + + TfUtf8CodePointView view(source); + auto it = view.begin(); + const auto end = view.end(); + const auto underscore = TfUtf8CodePointFromAscii('_'); + + // Defensive: a non-empty std::string always yields >=1 codepoint (invalid + // bytes decode to U+FFFD), so this is belt-and-suspenders, but it lets the + // reader stop reasoning about the view's non-empty guarantee before *it. + if (it == end) { + return "_"; + } + + // Position 0: must be XID_Start or '_'. The explicit underscore check is + // required: '_' is not XID_Start, and omitting it would double a leading + // '_' (since '_' is XID_Continue, the else-branch would prepend then append). + if (TfIsUtf8CodePointXidStart(*it) || *it == underscore) { + _appendUtf8CodePoint(result, *it); + } else { + result += '_'; + // A leading XID_Continue-but-not-XID_Start codepoint (e.g. a digit) is + // preserved after the prepended '_'. + if (TfIsUtf8CodePointXidContinue(*it)) { + _appendUtf8CodePoint(result, *it); + } + } + ++it; + + // Position N>0: each codepoint must be XID_Continue (which includes '_' via + // the Pc category; non-XID codepoints, including spaces and punctuation, + // collapse to '_'). + for (; it != end; ++it) { + if (TfIsUtf8CodePointXidContinue(*it)) { + _appendUtf8CodePoint(result, *it); + } else { + result += '_'; + } + } + + return result; +} + +} // namespace adobe::usd diff --git a/utils/src/sdfUtils.cpp b/utils/src/sdfUtils.cpp index 6adf7a36..0a49091b 100644 --- a/utils/src/sdfUtils.cpp +++ b/utils/src/sdfUtils.cpp @@ -14,17 +14,31 @@ governing permissions and limitations under the License. #include #include +#include #include #include #include #include // for UsdTokens->apiSchemas +#include #include +#include PXR_NAMESPACE_USING_DIRECTIVE namespace { // anonymous namespace +// A duplicate child name under one parent isn't caught when writing USDA (the defs merge), but +// USDC rejects it (crateData enforces unique primChildren). Flagging it at author time points at +// the offending prim instead of surfacing later as an opaque "Invalid Layer" failure on load. +template +void +_reportDuplicateChild(const SdfPath& parentPath, const T& child) +{ + TF_CODING_ERROR( + "Duplicate child prim '%s' under '%s'", TfStringify(child).c_str(), parentPath.GetText()); +} + template void _appendChild(SdfAbstractData* data, @@ -36,6 +50,12 @@ _appendChild(SdfAbstractData* data, // Retrieve the existing children first, if they existed SdfAbstractDataTypedValue getter(&children); (void)data->Has(specPath, childKey, &getter); + // Scoped to PrimChildren. The scan is over the child vector this function already reads and + // rewrites each call, so it doesn't add to this path's complexity. + if (childKey == SdfChildrenKeys->PrimChildren && + std::find(children.begin(), children.end(), child) != children.end()) { + _reportDuplicateChild(specPath, child); + } children.push_back(child); data->Set(specPath, childKey, SdfAbstractDataConstTypedValue(&children)); } @@ -165,6 +185,15 @@ appendToChildList(SdfAbstractData* data, // Retrieve the existing children first, if they existed SdfAbstractDataTypedValue getter(¤tChildren); (void)data->Has(parentPrimPath, SdfChildrenKeys->PrimChildren, &getter); + // Flag duplicates -- against existing children and within the incoming batch. The set + // keeps this O(n), preserving the batched-append fast path. + std::unordered_set seen(currentChildren.begin(), + currentChildren.end()); + for (const TfToken& child : children) { + if (!seen.insert(child).second) { + _reportDuplicateChild(parentPrimPath, child); + } + } if (currentChildren.empty()) { data->Set(parentPrimPath, SdfChildrenKeys->PrimChildren, @@ -300,6 +329,33 @@ setAttributeDefaultValue(SdfAbstractData* data, data->Set(propertyPath, SdfFieldKeys->Default, value); } +void +writeCustomProperties(SdfAbstractData* data, + const SdfPath& primPath, + const VtDictionary& properties) +{ + if (properties.empty()) + return; + // Deep-merge into any customData already on the prim: entries in `properties` win on key + // collision, while existing sibling sub-trees are preserved. Recursive (not shallow) merge so + // independent writers contributing under nested keys compose instead of clobbering each other's + // sub-dictionaries. + VtDictionary customData; + SdfAbstractDataTypedValue getter(&customData); + data->Has(primPath, SdfFieldKeys->CustomData, &getter); // leaves customData empty if unset + VtDictionaryOverRecursive(properties, &customData); // merged result is left in customData + setPrimMetadata(data, primPath, SdfFieldKeys->CustomData, VtValue(customData)); +} + +void +writeCustomProperty(SdfAbstractData* data, + const SdfPath& primPath, + const std::string& key, + const VtValue& value) +{ + writeCustomProperties(data, primPath, VtDictionary{ { key, value } }); +} + void setAttributeTimeSampledValues(SdfAbstractData* data, const SdfPath& propertyPath, diff --git a/utils/src/test.cpp b/utils/src/test.cpp index 38f995ae..f3247379 100644 --- a/utils/src/test.cpp +++ b/utils/src/test.cpp @@ -11,6 +11,7 @@ governing permissions and limitations under the License. */ #include +#include #include #include #include @@ -426,8 +427,7 @@ assertArray(const pxr::VtArray& actual, ASSERT_GE_VAL(actual.size(), expected.values.size(), "There are fewer " + name + " than elements to be checked."); - size_t i; - for (i = 0; i < expected.values.size(); i++) { + for (size_t i = 0; i < expected.values.size(); i++) { ASSERT_EQ_VAL( actual[i], expected.values[i], name + " element at index " + std::to_string(i)); } @@ -1024,8 +1024,13 @@ assertMaterial(PXR_NS::UsdStageRefPtr stage, const std::string& path, const Mate std::string("UsdUVTexture"), "Shader at path " + textureShaderPath.GetString() + " is not a UsdUVTexture"); - const std::string assetPath = TfNormPath(currentDir + "/" + data.file); - ASSERT_CHECK(assertInputPath(textureShader, "file", assetPath)); + // TODO:: All that two commented lines do is check to see if the file is + // relative to the test executable. What if it isn't, such as within an + // alternate test workflow? + + // const std::string assetPath = TfNormPath(currentDir + "/" + data.file); + // ASSERT_CHECK(assertInputPath(textureShader, "file", assetPath)); + // TODO? ASSERT_IMAGE(ctx, assetPath, input.image); ASSERT_CHECK(assertInputField(textureShader, "wrapS", data.wrapS)); ASSERT_CHECK(assertInputField(textureShader, "wrapT", data.wrapT)); @@ -1258,3 +1263,19 @@ assertUsda(const SdfLayerHandle& sdfLayer, } return ::testing::AssertionSuccess(); } + +PXR_NS::UsdStageRefPtr +openAssetStage(const std::string& path) +{ + EXPECT_TRUE(std::filesystem::exists(std::filesystem::u8path(path))) + << "File not found on disk: " << path; + return PXR_NS::UsdStage::Open(path); +} + +PXR_NS::UsdStageRefPtr +openAssetStage(const std::string& path, const std::string& formatArgs) +{ + EXPECT_TRUE(std::filesystem::exists(std::filesystem::u8path(path))) + << "File not found on disk: " << path; + return PXR_NS::UsdStage::Open(path + ":SDF_FORMAT_ARGS:" + formatArgs); +} diff --git a/utils/src/usdData.cpp b/utils/src/usdData.cpp index 462f1f2d..07ec192f 100644 --- a/utils/src/usdData.cpp +++ b/utils/src/usdData.cpp @@ -12,8 +12,10 @@ governing permissions and limitations under the License. #include #include #include +#include #include #include +#include #include @@ -163,6 +165,16 @@ printClearcoatModelsTransmissionTint(const Material& material) } } +std::string +printClearcoatModelsTransmissionTint(const OpenPbrMaterial& material) +{ + if (!material.clearcoatModelsTransmissionTint) { + return {}; + } else { + return "\n clearcoatModelsTransmissionTint = true"; + } +} + std::string printUnlit(const Material& material) { @@ -173,6 +185,16 @@ printUnlit(const Material& material) } } +std::string +printUnlit(const OpenPbrMaterial& material) +{ + if (!material.isUnlit) { + return {}; + } else { + return "\n unlit = true"; + } +} + void printMaterial(const std::string& header, const SdfPath& path, @@ -221,6 +243,71 @@ printMaterial(const std::string& header, printUnlit(material).c_str()); } +void +printOpenPbrMaterial(const std::string& header, + const SdfPath& path, + const OpenPbrMaterial& material, + const std::string& debugTag) +{ + TF_DEBUG_MSG( + FILE_FORMAT_UTIL, + "%s: %s openPbrMaterial { " + "%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s\n", + debugTag.c_str(), + header.c_str(), + path.GetAsString().c_str(), + printInput(OpenPbrTokens->base_weight, material.base_weight).c_str(), + printInput(OpenPbrTokens->base_color, material.base_color).c_str(), + printInput(OpenPbrTokens->base_diffuse_roughness, material.base_diffuse_roughness).c_str(), + printInput(OpenPbrTokens->base_metalness, material.base_metalness).c_str(), + printInput(OpenPbrTokens->specular_weight, material.specular_weight).c_str(), + printInput(OpenPbrTokens->specular_color, material.specular_color).c_str(), + printInput(OpenPbrTokens->specular_roughness, material.specular_roughness).c_str(), + printInput(OpenPbrTokens->specular_ior, material.specular_ior).c_str(), + printInput(OpenPbrTokens->specular_roughness_anisotropy, + material.specular_roughness_anisotropy) + .c_str(), + printInput(OpenPbrTokens->transmission_weight, material.transmission_weight).c_str(), + printInput(OpenPbrTokens->transmission_scatter, material.transmission_scatter).c_str(), + printInput(OpenPbrTokens->transmission_scatter_anisotropy, + material.transmission_scatter_anisotropy) + .c_str(), + printInput(OpenPbrTokens->transmission_dispersion_scale, + material.transmission_dispersion_scale) + .c_str(), + printInput(OpenPbrTokens->transmission_dispersion_abbe_number, + material.transmission_dispersion_abbe_number) + .c_str(), + printInput(OpenPbrTokens->subsurface_weight, material.subsurface_weight).c_str(), + printInput(OpenPbrTokens->subsurface_color, material.subsurface_color).c_str(), + printInput(OpenPbrTokens->subsurface_radius, material.subsurface_radius).c_str(), + printInput(OpenPbrTokens->subsurface_radius_scale, material.subsurface_radius_scale).c_str(), + printInput(OpenPbrTokens->subsurface_scatter_anisotropy, + material.subsurface_scatter_anisotropy) + .c_str(), + printInput(OpenPbrTokens->fuzz_weight, material.fuzz_weight).c_str(), + printInput(OpenPbrTokens->fuzz_color, material.fuzz_color).c_str(), + printInput(OpenPbrTokens->fuzz_roughness, material.fuzz_roughness).c_str(), + printInput(OpenPbrTokens->coat_weight, material.coat_weight).c_str(), + printInput(OpenPbrTokens->coat_color, material.coat_color).c_str(), + printInput(OpenPbrTokens->coat_roughness, material.coat_roughness).c_str(), + printInput(OpenPbrTokens->coat_roughness_anisotropy, material.coat_roughness_anisotropy) + .c_str(), + printInput(OpenPbrTokens->coat_ior, material.coat_ior).c_str(), + printInput(OpenPbrTokens->coat_darkening, material.coat_darkening).c_str(), + printInput(OpenPbrTokens->thin_film_weight, material.thin_film_weight).c_str(), + printInput(OpenPbrTokens->thin_film_thickness, material.thin_film_thickness).c_str(), + printInput(OpenPbrTokens->thin_film_ior, material.thin_film_ior).c_str(), + printInput(OpenPbrTokens->emission_luminance, material.emission_luminance).c_str(), + printInput(OpenPbrTokens->emission_color, material.emission_color).c_str(), + printInput(OpenPbrTokens->geometry_opacity, material.geometry_opacity).c_str(), + printInput(OpenPbrTokens->geometry_thin_walled, material.geometry_thin_walled).c_str(), + printInput(OpenPbrTokens->geometry_normal, material.geometry_normal).c_str(), + printInput(OpenPbrTokens->geometry_coat_normal, material.geometry_coat_normal).c_str(), + printInput(OpenPbrTokens->geometry_tangent, material.geometry_tangent).c_str(), + printInput(OpenPbrTokens->geometry_coat_tangent, material.geometry_coat_tangent).c_str()); +} + void printMesh(const std::string& header, const Mesh& mesh, const std::string& debugTag) { @@ -296,6 +383,8 @@ getFormat(const std::string& extension) return ImageFormatTiff; else if (s == "webp") return ImageFormatWebp; + else if (s == "hdr") + return ImageFormatHdr; else TF_WARN("getFormat for unsupported extension '%s'", extension.c_str()); return ImageFormatUnknown; @@ -321,6 +410,8 @@ getFormatExtension(ImageFormat format) return "tiff"; case ImageFormatWebp: return "webp"; + case ImageFormatHdr: + return "hdr"; case ImageFormatUnknown: default: TF_WARN("getFormatExtension for unknown extension"); @@ -328,6 +419,62 @@ getFormatExtension(ImageFormat format) } } +ImageFormat +getFormatFromMimeType(std::string_view mime) +{ + // format <- MIME type; multiple MIME types (primary + aliases) map to one format. + static constexpr std::pair kFormatMimeTypes[] = { + { ImageFormatBmp, "image/bmp" }, + { ImageFormatBmp, "image/x-bmp" }, + { ImageFormatBmp, "image/x-bitmap" }, + { ImageFormatBmp, "image/x-xbitmap" }, + { ImageFormatBmp, "image/x-win-bitmap" }, + { ImageFormatBmp, "image/x-windows-bmp" }, + { ImageFormatBmp, "image/ms-bmp" }, + { ImageFormatBmp, "image/x-ms-bmp" }, + + { ImageFormatExr, "image/x-exr" }, + { ImageFormatExr, "image/exr" }, + + { ImageFormatHdr, "image/vnd.radiance" }, + { ImageFormatHdr, "image/hdr" }, + + { ImageFormatJpg, "image/jpeg" }, + { ImageFormatJpg, "image/jpg" }, + + { ImageFormatPng, "image/png" }, + + { ImageFormatPsd, "image/vnd.adobe.photoshop" }, + { ImageFormatPsd, "image/psd" }, + { ImageFormatPsd, "image/photoshop" }, + { ImageFormatPsd, "image/x-photoshop" }, + { ImageFormatPsd, "application/psd" }, + { ImageFormatPsd, "application/photoshop" }, + { ImageFormatPsd, "application/x-photoshop" }, + + { ImageFormatTga, "image/tga" }, + { ImageFormatTga, "image/x-tga" }, + { ImageFormatTga, "image/targa" }, + { ImageFormatTga, "image/x-targa" }, + { ImageFormatTga, "application/tga" }, + { ImageFormatTga, "application/x-tga" }, + { ImageFormatTga, "application/x-targa" }, + + { ImageFormatTiff, "image/tiff" }, + { ImageFormatTiff, "image/tif" }, + + { ImageFormatWebp, "image/webp" }, + }; + + for (const auto& [format, mimeType] : kFormatMimeTypes) { + if (mime == mimeType) + return format; + } + // MIME types with no corresponding ImageFormat fall through to Unknown; the caller + // serves bytes without a format hint rather than failing. + return ImageFormatUnknown; +} + std::pair UsdData::addNode(int parent) { @@ -422,6 +569,14 @@ UsdData::addMaterial() return { index, materials[index] }; } +std::pair +UsdData::addOpenPbrMaterial() +{ + int index = openPbrMaterials.size(); + openPbrMaterials.push_back(OpenPbrMaterial()); + return { index, openPbrMaterials[index] }; +} + std::pair UsdData::addCamera() { @@ -471,7 +626,7 @@ UsdData::addNgp() std::string _makeValidPrimName(const std::string& name, const std::string& defaultName) { - return name.empty() ? defaultName : TfMakeValidIdentifier(name); + return name.empty() ? defaultName : MakeValidUsdIdentifier(name); } /** @@ -513,7 +668,7 @@ _makeValidPrimName(const std::string& nodeName, newNodeName = _makeValidPrimName(displayName, defaultName); newDisplayName = (newNodeName == displayName ? "" : displayName); } else { - newNodeName = TfMakeValidIdentifier(nodeName); + newNodeName = MakeValidUsdIdentifier(nodeName); newDisplayName = displayName; } @@ -639,8 +794,69 @@ void _uniquifyNode(UsdData& data, Node& node) { _uniquifySiblings(data.nurbs, node.nurbs, "Nurb"); - _uniquifySiblingMeshes(data.meshes, node.staticMeshes); - _uniquifySiblings(data.nodes, node.children, "Node"); + + // Camera, light, meshes (including instanceable), curves, and child nodes all become direct + // USD prim children of the same Xform prim, so they must be unique within a shared namespace. + // Without this, any two with the same name collide at write time: the first is written as its + // correct type, then the second call to createPrimSpec unconditionally overwrites the TypeName + // field, leaving orphaned attributes on a prim of the wrong type. NGP always writes at the + // hardcoded path "vol/ngp" and cannot collide. + // Camera and light are seeded first since _writeNode writes them before geometry. + static const std::string pointsStr = "Points"; + static const std::string meshStr = "Mesh"; + std::unordered_map childPrimNames; + + // Camera and light are seeded first because _writeNode writes them before geometry. Seeding + // here reserves their names so that geometry processed below is renamed on collision rather + // than the camera or light. uniquifyNames already made these valid identifiers globally, but + // did not uniquify them against the geometry siblings of the same node. + if (node.camera >= 0) { + Camera& camera = data.cameras[node.camera]; + auto [name, displayName] = _makeValidPrimName(camera.name, camera.displayName, "Camera"); + camera.name = name; + camera.displayName = displayName; + _makeUniqueAndAdd(childPrimNames, camera.name, &camera.displayName); + } + + if (node.light >= 0) { + Light& light = data.lights[node.light]; + auto [name, displayName] = _makeValidPrimName(light.name, light.displayName, "Light"); + light.name = name; + light.displayName = displayName; + _makeUniqueAndAdd(childPrimNames, light.name, &light.displayName); + } + + // _uniquifySiblingMeshes cannot be used here: it owns its own namespace map, so it cannot + // detect collisions with curves or child nodes. Instanceable meshes write an Xform instance + // prim directly under the parent (layerWriteSdfData.cpp _writeInstancedMesh), so they occupy + // a slot in this namespace and must participate in uniquification like any other sibling. + for (int idx : node.staticMeshes) { + Mesh& mesh = data.meshes[idx]; + const std::string& defaultName = mesh.asPoints ? pointsStr : meshStr; + auto [name, displayName] = _makeValidPrimName(mesh.name, mesh.displayName, defaultName); + mesh.name = name; + mesh.displayName = displayName; + _makeUniqueAndAdd(childPrimNames, mesh.name, &mesh.displayName); + } + + // _uniquifySiblings cannot be used here: it owns its own namespace map, so it cannot + // detect collisions with meshes or child nodes. Curve has no displayName field, so + // _makeUniqueAndAdd is called without the optional display name pointer. + for (int idx : node.curves) { + Curve& curve = data.curves[idx]; + curve.name = _makeValidPrimName(curve.name, "Curve"); + _makeUniqueAndAdd(childPrimNames, curve.name); + } + + // _uniquifySiblings cannot be used here: it owns its own namespace map, so it cannot + // detect collisions with meshes or curves. + for (int idx : node.children) { + Node& child = data.nodes[idx]; + auto [name, displayName] = _makeValidPrimName(child.name, child.displayName, "Node"); + child.name = name; + child.displayName = displayName; + _makeUniqueAndAdd(childPrimNames, child.name, &child.displayName); + } for (int idx : node.children) { _uniquifyNode(data, data.nodes[idx]); @@ -666,6 +882,7 @@ uniquifyNames(UsdData& data) light.displayName = displayName; } _uniquifySiblings(data.materials, "Material"); + _uniquifySiblings(data.openPbrMaterials, "Material"); _uniquifySiblings(data.skeletons, "Skeleton"); for (Skeleton& skeleton : data.skeletons) { @@ -727,9 +944,9 @@ shouldConvertToSRGB(const UsdData& usd, const std::string& outputColorSpace) } void -UniqueNameEnforcer::enforceUniqueness(std::string& name) +UniqueNameEnforcer::enforceUniqueness(std::string& name, std::string* displayName) { - _makeUniqueAndAdd(namesMap, name); + _makeUniqueAndAdd(namesMap, name, displayName); } void @@ -757,7 +974,7 @@ trimDegenerateNormals(Mesh& mesh) faceIdx < mesh.faces.size() && normalIdx + 2 < mesh.normals.values.size(); ++faceIdx) { double triangleArea = -1; - for (size_t i = 0; i < mesh.faces[faceIdx] && normalIdx + 2 < mesh.normals.values.size(); + for (int i = 0; i < mesh.faces[faceIdx] && normalIdx + 2 < mesh.normals.values.size(); ++i) { // We iterate over the elements of the face so that we don't have to recalculate the diff --git a/utils/tests/CMakeLists.txt b/utils/tests/CMakeLists.txt index 1acc77a7..fdf4802d 100644 --- a/utils/tests/CMakeLists.txt +++ b/utils/tests/CMakeLists.txt @@ -13,10 +13,9 @@ target_link_libraries(utilsTests usdShade GTest::gtest GTest::gtest_main - fileformatUtils + fileformatUtilsTest ) add_test(NAME utilsTests COMMAND utilsTests WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) - diff --git a/utils/tests/data/baseline_writeASM.usda b/utils/tests/data/baseline_writeASM.usda index fdc4bc86..e11e6b65 100644 --- a/utils/tests/data/baseline_writeASM.usda +++ b/utils/tests/data/baseline_writeASM.usda @@ -284,10 +284,10 @@ def Xform "Scene" def Shader "normal" { uniform token info:id = "UsdUVTexture" - float4 inputs:bias = (-0.75, -0.75, -0.75, 0) + float4 inputs:bias = (-1, -1, -1, 0) asset inputs:file = @textures/normal.png@ asset inputs:file.connect = - float4 inputs:scale = (1.5, 1.5, 1.5, 0.75) + float4 inputs:scale = (2, 2, 2, 1) token inputs:sourceColorSpace = "raw" float2 inputs:st.connect = float3 outputs:rgb @@ -365,6 +365,61 @@ def Xform "Scene" } } + def Material "ScaledNormalTestMaterial" ( + displayName = "Scaled Normal Test Material" + ) + { + asset inputs:coatNormalTexture = @textures/normal.png@ ( + colorSpace = "raw" + ) + asset inputs:normalTexture = @textures/normal.png@ ( + colorSpace = "raw" + ) + token outputs:adobe:surface.connect = + + def NodeGraph "ASM" + { + def Shader "texCoordReader" + { + uniform token info:id = "UsdPrimvarReader_float2" + string inputs:varname = "st" + float2 outputs:result + } + + def Shader "normal" + { + uniform token info:id = "UsdUVTexture" + float4 inputs:bias = (-0.75, -0.75, -0.75, 0) + asset inputs:file = @textures/normal.png@ + asset inputs:file.connect = + float4 inputs:scale = (1.5, 1.5, 1.5, 0.75) + token inputs:sourceColorSpace = "raw" + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "coatNormal" + { + uniform token info:id = "UsdUVTexture" + float4 inputs:bias = (-0.75, -0.75, -0.75, 0) + asset inputs:file = @textures/normal.png@ + asset inputs:file.connect = + float4 inputs:scale = (1.5, 1.5, 1.5, 0.75) + token inputs:sourceColorSpace = "raw" + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "ASM" + { + uniform token info:id = "AdobeStandardMaterial_4_0" + normal3f inputs:coatNormal.connect = + normal3f inputs:normal.connect = + token outputs:surface + } + } + } + def Material "TransmissionTestMaterial" ( displayName = "Transmission Test Material" ) diff --git a/utils/tests/data/baseline_writeOpenPBR.usda b/utils/tests/data/baseline_writeOpenPBR.usda index b7ae7078..bcba8068 100644 --- a/utils/tests/data/baseline_writeOpenPBR.usda +++ b/utils/tests/data/baseline_writeOpenPBR.usda @@ -292,9 +292,7 @@ def Xform "Scene" def Shader "geometry_normal" { uniform token info:id = "ND_UsdUVTexture_23" - float4 inputs:bias = (0.125, 0.125, 0.125, 0) asset inputs:file.connect = - float4 inputs:scale = (0.75, 0.75, 0.75, 0.75) float2 inputs:st.connect = string inputs:wrapS = "periodic" string inputs:wrapT = "periodic" @@ -382,6 +380,87 @@ def Xform "Scene" } } + def Material "ScaledNormalTestMaterial" ( + displayName = "Scaled Normal Test Material" + ) + { + asset inputs:coatNormalTexture = @textures/normal.png@ ( + colorSpace = "raw" + ) + asset inputs:normalTexture = @textures/normal.png@ ( + colorSpace = "raw" + ) + token outputs:mtlx:surface.connect = + + def NodeGraph "OpenPBR" + { + def Shader "texCoordReader" + { + uniform token info:id = "ND_geompropvalue_vector2" + string inputs:geomprop = "st" + float2 outputs:out + } + + def Shader "geometry_normal" + { + uniform token info:id = "ND_UsdUVTexture_23" + asset inputs:file.connect = + float2 inputs:st.connect = + string inputs:wrapS = "periodic" + string inputs:wrapT = "periodic" + color3f outputs:rgb + } + + def Shader "geometry_normal_as_vector" + { + uniform token info:id = "ND_convert_color3_vector3" + color3f inputs:in.connect = + float3 outputs:out + } + + def Shader "geometry_normal_to_world_space" + { + uniform token info:id = "ND_normalmap" + float3 inputs:in.connect = + float inputs:scale = 0.75 + float3 outputs:out + } + + def Shader "geometry_coat_normal" + { + uniform token info:id = "ND_UsdUVTexture_23" + asset inputs:file.connect = + float2 inputs:st.connect = + string inputs:wrapS = "periodic" + string inputs:wrapT = "periodic" + color3f outputs:rgb + } + + def Shader "geometry_coat_normal_as_vector" + { + uniform token info:id = "ND_convert_color3_vector3" + color3f inputs:in.connect = + float3 outputs:out + } + + def Shader "geometry_coat_normal_to_world_space" + { + uniform token info:id = "ND_normalmap" + float3 inputs:in.connect = + float inputs:scale = 0.75 + float3 outputs:out + } + + def Shader "OpenPBR" + { + uniform token info:id = "ND_open_pbr_surface_surfaceshader" + float3 inputs:geometry_coat_normal.connect = + float3 inputs:geometry_normal.connect = + token outputs:out + } + } + } + def Material "TransmissionTestMaterial" ( displayName = "Transmission Test Material" ) diff --git a/utils/tests/data/baseline_writeUsdPreviewSurface.usda b/utils/tests/data/baseline_writeUsdPreviewSurface.usda index 72a16e05..0ccb529d 100644 --- a/utils/tests/data/baseline_writeUsdPreviewSurface.usda +++ b/utils/tests/data/baseline_writeUsdPreviewSurface.usda @@ -230,10 +230,10 @@ def Xform "Scene" def Shader "normal" { uniform token info:id = "UsdUVTexture" - float4 inputs:bias = (-0.75, -0.75, -0.75, 0) + float4 inputs:bias = (-1, -1, -1, 0) asset inputs:file = @textures/normal.png@ asset inputs:file.connect = - float4 inputs:scale = (1.5, 1.5, 1.5, 0.75) + float4 inputs:scale = (2, 2, 2, 1) token inputs:sourceColorSpace = "raw" float2 inputs:st.connect = float3 outputs:rgb @@ -264,6 +264,47 @@ def Xform "Scene" } } + def Material "ScaledNormalTestMaterial" ( + displayName = "Scaled Normal Test Material" + ) + { + asset inputs:normalTexture = @textures/normal.png@ ( + colorSpace = "raw" + ) + token outputs:displacement.connect = + token outputs:surface.connect = + + def NodeGraph "UsdPreviewSurface" + { + def Shader "texCoordReader" + { + uniform token info:id = "UsdPrimvarReader_float2" + string inputs:varname = "st" + float2 outputs:result + } + + def Shader "normal" + { + uniform token info:id = "UsdUVTexture" + float4 inputs:bias = (-0.75, -0.75, -0.75, 0) + asset inputs:file = @textures/normal.png@ + asset inputs:file.connect = + float4 inputs:scale = (1.5, 1.5, 1.5, 0.75) + token inputs:sourceColorSpace = "raw" + float2 inputs:st.connect = + float3 outputs:rgb + } + + def Shader "UsdPreviewSurface" + { + uniform token info:id = "UsdPreviewSurface" + normal3f inputs:normal.connect = + token outputs:displacement + token outputs:surface + } + } + } + def Material "TransmissionTestMaterial" ( displayName = "Transmission Test Material" ) diff --git a/utils/tests/tests.cpp b/utils/tests/tests.cpp index 602530c1..858e706c 100644 --- a/utils/tests/tests.cpp +++ b/utils/tests/tests.cpp @@ -12,15 +12,29 @@ governing permissions and limitations under the License. #include #include +#include +#include #include #include #include #include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include #include #include +#include +#include +#include #include @@ -147,16 +161,13 @@ fillTextureTestMaterial(UsdData& data) input.image = normalId; input.channel = AdobeTokens->rgb; input.colorspace = AdobeTokens->raw; - // GLTF files sometimes scale the normals - const float nmScale = 0.75f; - input.scale = GfVec4f(2.0f, 2.0f, 2.0f, 1.0f) * nmScale; - input.bias = GfVec4f(-1.0f, -1.0f, -1.0f, 0.0f) * nmScale; + input.scale = kOpenGLNormalTexScale; + input.bias = kOpenGLNormalTexBias; m.normal = input; m.clearcoatNormal = input; // Put DirectX convention decoding values on the clearcoat normals - m.clearcoatNormal.scale = GfVec4f(2.0f, -2.0f, 2.0f, 1.0f); - m.clearcoatNormal.bias = GfVec4f(-1.0f, 1.0f, -1.0f, 0.0f); - // XXX test normal map scale the way GLTF can create + m.clearcoatNormal.scale = kDirectXNormalTexScale; + m.clearcoatNormal.bias = kDirectXNormalTexBias; } // Greyscale maps @@ -197,6 +208,33 @@ fillTextureTestMaterial(UsdData& data) } } +void +fillScaledNormalTestMaterial(UsdData& data) +{ + auto [normalId, normalImage] = data.addImage(); + normalImage.name = "normal"; + normalImage.uri = "textures/normal.png"; + normalImage.format = ImageFormat::ImageFormatPng; + + Material& m = data.addMaterial().second; + m.name = "ScaledNormalTestMaterial"; + m.displayName = "Scaled Normal Test Material"; + + // Scaled OpenGL convention normals on both the primary and clearcoat normal slots. This + // exercises the OpenPBR write path's extraction of normalScale onto ND_normalmap's scale + // input (the standard MaterialX normal strength pattern, e.g. glTF KHR_materials_normalTexture + // scale). + Input input; + input.image = normalId; + input.channel = AdobeTokens->rgb; + input.colorspace = AdobeTokens->raw; + const float nmScale = 0.75f; + input.scale = kOpenGLNormalTexScale * nmScale; + input.bias = kOpenGLNormalTexBias * nmScale; + m.normal = input; + m.clearcoatNormal = input; +} + void fillTransmissionMaterial(UsdData& data) { @@ -221,6 +259,192 @@ fillTransmissionMaterialAsUsdPreviewSurfaceBaseline(UsdData& data) m.opacity = Input{ VtValue(1.0f - 0.543f) }; } +OpenPbrMaterial& +fillGeneralTestOpenPbrMaterial(UsdData& data) +{ + OpenPbrMaterial& m = data.addOpenPbrMaterial().second; + m.name = "GeneralTestMaterial"; + m.displayName = "General Test Material"; + + m.useSpecularWorkflow = true; + m.base_color = Input{ VtValue(GfVec3f(1.0f, 0.5f, 0.25f)) }; + m.emission_color = Input{ VtValue(GfVec3f(1.0f, 0.5f, 0.25f)) }; + m.emission_luminance = Input{ VtValue(kAsmToOpenPbrEmissionFactor) }; + m.specular_weight = Input{ VtValue(0.5f) }; + m.specular_color = Input{ VtValue(GfVec3f(1.0f, 0.0f, 1.0f)) }; + m.geometry_normal = Input{ VtValue(GfVec3f(0.5f, 0.5f, 0.5f)) }; + m.normalScale = 0.5f; + m.base_metalness = Input{ VtValue(0.22f) }; + m.specular_roughness = Input{ VtValue(0.66f) }; + m.coat_weight = Input{ VtValue(0.55f) }; + m.coat_color = Input{ VtValue(GfVec3f(1.0f, 1.0f, 0.0f)) }; + m.coat_roughness = Input{ VtValue(0.44f) }; + m.coat_ior = Input{ VtValue(1.33f) }; + m.coatSpecularLevel = Input{ VtValue(0.88f) }; + m.geometry_coat_normal = Input{ VtValue(GfVec3f(0.66f, 0.0f, 0.66f)) }; + m.fuzz_weight = Input{ VtValue(1.0f) }; + m.fuzz_color = Input{ VtValue(GfVec3f(0.0f, 1.0f, 1.0f)) }; + m.fuzz_roughness = Input{ VtValue(0.5f) }; + m.specular_roughness_anisotropy = Input{ VtValue(0.321f) }; + m.anisotropyAngle = Input{ VtValue(0.777f) }; + m.geometry_opacity = Input{ VtValue(0.8f) }; + m.opacityThreshold = 0.75f; + m.displacement = Input{ VtValue(1.23f) }; + m.occlusion = Input{ VtValue(0.01f) }; + m.specular_ior = Input{ VtValue(1.55f) }; + m.transmission_weight = Input{ VtValue(0.123f) }; + m.volumeThickness = Input{ VtValue(0.987f) }; + m.transmission_depth = Input{ VtValue(111.0f) }; + m.transmission_color = Input{ VtValue(GfVec3f(0.25f, 0.5f, 1.0f)) }; + m.subsurface_weight = Input{ VtValue(1.0f) }; + m.subsurface_radius = Input{ VtValue(222.0f) }; + m.subsurface_color = Input{ VtValue(GfVec3f(1.0f, 0.5f, 1.0f)) }; + m.clearcoatModelsTransmissionTint = true; + m.isUnlit = true; + return m; +} + +void +fillTextureTestOpenPbrMaterial(UsdData& data) +{ + auto [colorId, colorImage] = data.addImage(); + colorImage.name = "color"; + colorImage.uri = "textures/color.png"; + colorImage.format = ImageFormat::ImageFormatPng; + auto [normalId, normalImage] = data.addImage(); + normalImage.name = "normal"; + normalImage.uri = "textures/normal.png"; + normalImage.format = ImageFormat::ImageFormatPng; + auto [greyscaleId, greyscaleImage] = data.addImage(); + greyscaleImage.name = "greyscale"; + greyscaleImage.uri = "textures/greyscale.png"; + greyscaleImage.format = ImageFormat::ImageFormatPng; + auto [occlusionId, occlusionImage] = data.addImage(); + occlusionImage.name = "occlusion"; + occlusionImage.uri = "textures/occlusion.png"; + occlusionImage.format = ImageFormat::ImageFormatPng; + + OpenPbrMaterial& m = data.addOpenPbrMaterial().second; + m.name = "TextureTestMaterial"; + m.displayName = "Texture Test Material"; + + // Color textures + { + Input input; + input.image = colorId; + input.channel = AdobeTokens->rgb; + input.colorspace = AdobeTokens->sRGB; + m.base_color = input; + + input.wrapS = AdobeTokens->clamp; + input.wrapT = AdobeTokens->mirror; + input.scale = GfVec4f(1.0f, 2.0f, 0.5f, 1.0f); + input.bias = GfVec4f(0.1f, 0.2f, 0.3f, 0.0f); + input.uvRotation = 15.0f; + input.uvScale = GfVec2f(1.5f, 0.75f); + input.uvTranslation = GfVec2f(0.12f, 3.45f); + m.emission_color = input; + } + m.emission_luminance = Input{ VtValue(kAsmToOpenPbrEmissionFactor) }; + + // Normal maps + { + Input input; + input.image = normalId; + input.channel = AdobeTokens->rgb; + input.colorspace = AdobeTokens->raw; + input.scale = kOpenGLNormalTexScale; + input.bias = kOpenGLNormalTexBias; + m.geometry_normal = input; + m.geometry_coat_normal = input; + // Put DirectX convention decoding values on the clearcoat normals + m.geometry_coat_normal.scale = kDirectXNormalTexScale; + m.geometry_coat_normal.bias = kDirectXNormalTexBias; + } + + // Greyscale maps + { + Input input; + input.image = greyscaleId; + input.channel = AdobeTokens->r; + input.colorspace = AdobeTokens->raw; + + input.wrapS = AdobeTokens->black; + input.wrapT = AdobeTokens->black; + input.scale = GfVec4f(0.55f, 1.0f, 1.0f, 1.0f); + input.bias = GfVec4f(0.1f, 0.0f, 0.0f, 0.0f); + input.uvRotation = 15.0f; + input.uvScale = GfVec2f(1.5f, 0.75f); + input.uvTranslation = GfVec2f(0.12f, 3.45f); + m.specular_roughness = input; + } + + // Occlusion maps + { + Input input; + input.image = occlusionId; + input.channel = AdobeTokens->r; + input.colorspace = AdobeTokens->raw; + + m.occlusion = input; + } + + // Single channel from RGB map + { + Input input; + input.image = colorId; + input.channel = AdobeTokens->g; + input.colorspace = AdobeTokens->raw; + m.coat_weight = input; + } +} + +void +fillScaledNormalTestOpenPbrMaterial(UsdData& data) +{ + auto [normalId, normalImage] = data.addImage(); + normalImage.name = "normal"; + normalImage.uri = "textures/normal.png"; + normalImage.format = ImageFormat::ImageFormatPng; + + OpenPbrMaterial& m = data.addOpenPbrMaterial().second; + m.name = "ScaledNormalTestMaterial"; + m.displayName = "Scaled Normal Test Material"; + + // Scaled OpenGL convention normals on both geometry_normal and geometry_coat_normal. The + // OpenPBR write path should extract the normalScale factor onto ND_normalmap's scale input + // for each, leaving the texture reader at identity. + Input input; + input.image = normalId; + input.channel = AdobeTokens->rgb; + input.colorspace = AdobeTokens->raw; + const float nmScale = 0.75f; + input.scale = kOpenGLNormalTexScale * nmScale; + input.bias = kOpenGLNormalTexBias * nmScale; + m.geometry_normal = input; + m.geometry_coat_normal = input; +} + +void +fillTransmissionOpenPbrMaterial(UsdData& data) +{ + OpenPbrMaterial& m = data.addOpenPbrMaterial().second; + m.name = "TransmissionTestMaterial"; + m.displayName = "Transmission Test Material"; + + m.transmission_weight = Input{ VtValue(0.543f) }; +} + +void +fillTransmissionOpenPbrMaterialAsUsdPreviewSurfaceBaseline(UsdData& data) +{ + OpenPbrMaterial& m = data.addOpenPbrMaterial().second; + m.name = "TransmissionTestMaterial"; + m.displayName = "Transmission Test Material"; + + m.geometry_opacity = Input{ VtValue(1.0f - 0.543f) }; +} + void compareInputs(const std::string& inputName, const Input& input, @@ -233,8 +457,8 @@ compareInputs(const std::string& inputName, // Either both image indices are invalid or both are valid EXPECT_EQ(input.image == -1, baseline.image == -1) << inputName; if (input.image != -1 && baseline.image != -1) { - ASSERT_TRUE(input.image < data.images.size()) << inputName; - ASSERT_TRUE(baseline.image < baselineData.images.size()) << inputName; + ASSERT_TRUE(input.image < static_cast(data.images.size())) << inputName; + ASSERT_TRUE(baseline.image < static_cast(baselineData.images.size())) << inputName; const ImageAsset& image = data.images[input.image]; const ImageAsset& baselineImage = baselineData.images[baseline.image]; EXPECT_EQ(image.name, baselineImage.name) << inputName; @@ -301,6 +525,56 @@ compareMaterials(const Material& material, #undef COMP_INPUT } +void +compareOpenPbrMaterials(const OpenPbrMaterial& material, + const OpenPbrMaterial& baseline, + const UsdData& data, + const UsdData& baselineData) +{ + EXPECT_EQ(material.name, baseline.name); + EXPECT_EQ(material.displayName, baseline.displayName); + + EXPECT_EQ(material.clearcoatModelsTransmissionTint, baseline.clearcoatModelsTransmissionTint); + EXPECT_EQ(material.isUnlit, baseline.isUnlit); + EXPECT_EQ(material.useSpecularWorkflow, baseline.useSpecularWorkflow); + EXPECT_FLOAT_EQ(material.normalScale, baseline.normalScale); + EXPECT_FLOAT_EQ(material.opacityThreshold, baseline.opacityThreshold); + +#define COMP_INPUT(x) compareInputs(#x, material.x, baseline.x, data, baselineData); + COMP_INPUT(base_color) + COMP_INPUT(base_metalness) + COMP_INPUT(specular_weight) + COMP_INPUT(specular_color) + COMP_INPUT(specular_roughness) + COMP_INPUT(specular_ior) + COMP_INPUT(specular_roughness_anisotropy) + COMP_INPUT(transmission_weight) + COMP_INPUT(transmission_color) + COMP_INPUT(transmission_depth) + COMP_INPUT(subsurface_weight) + COMP_INPUT(subsurface_color) + COMP_INPUT(subsurface_radius) + COMP_INPUT(subsurface_radius_scale) + COMP_INPUT(fuzz_weight) + COMP_INPUT(fuzz_color) + COMP_INPUT(fuzz_roughness) + COMP_INPUT(coat_weight) + COMP_INPUT(coat_color) + COMP_INPUT(coat_roughness) + COMP_INPUT(coat_ior) + COMP_INPUT(emission_luminance) + COMP_INPUT(emission_color) + COMP_INPUT(geometry_opacity) + COMP_INPUT(geometry_normal) + COMP_INPUT(geometry_coat_normal) + COMP_INPUT(displacement) + COMP_INPUT(occlusion) + COMP_INPUT(anisotropyAngle) + COMP_INPUT(coatSpecularLevel) + COMP_INPUT(volumeThickness) +#undef COMP_INPUT +} + TEST(FileFormatUtilsTests, materialStructConversions) { // Note, only Material -> OpenPbrMaterial -> Material needs to be preserving all information @@ -352,9 +626,18 @@ TEST(FileFormatUtilsTests, writeUsdPreviewSurface) SdfAbstractDataRefPtr sdfData(new SdfData()); UsdData data; - fillGeneralTestMaterial(data); - fillTextureTestMaterial(data); - fillTransmissionMaterial(data); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + if (useOpenPbr) { + fillGeneralTestOpenPbrMaterial(data); + fillTextureTestOpenPbrMaterial(data); + fillScaledNormalTestOpenPbrMaterial(data); + fillTransmissionOpenPbrMaterial(data); + } else { + fillGeneralTestMaterial(data); + fillTextureTestMaterial(data); + fillScaledNormalTestMaterial(data); + fillTransmissionMaterial(data); + } WriteLayerOptions options; options.writeUsdPreviewSurface = true; @@ -370,6 +653,54 @@ TEST(FileFormatUtilsTests, writeUsdPreviewSurface) ASSERT_USDA(layer, assetDir + "data/baseline_writeUsdPreviewSurface.usda"); } +// image.uri can originate in untrusted file content, so the assetsPath write +// loop must refuse any uri that resolves outside the chosen export directory. +TEST(FileFormatUtilsTests, assetsPathRejectsTraversalUris) +{ + namespace fs = std::filesystem; + const fs::path outDir = fs::temp_directory_path() / "fileformatutils-assetspath-traversal"; + const fs::path sentinelDir = outDir.parent_path(); + const fs::path escapedRel = sentinelDir / "fileformatutils-escape-rel.png"; + const fs::path escapedAbs = sentinelDir / "fileformatutils-escape-abs.png"; + fs::remove_all(outDir); + fs::remove(escapedRel); + fs::remove(escapedAbs); + + SdfLayerRefPtr layer = SdfLayer::CreateAnonymous("Scene.usda"); + SdfAbstractDataRefPtr sdfData(new SdfData()); + UsdData data; + + const std::vector bytes = { 1, 2, 3, 4 }; + // A benign nested uri (must be written) plus two hostile ones that try to + // escape assetsPath via "../" and via an absolute path (must be refused). + ImageAsset& safeImg = data.addImage().second; + safeImg.uri = "nested/safe.png"; + safeImg.image = bytes; + ImageAsset& relImg = data.addImage().second; + relImg.uri = "../fileformatutils-escape-rel.png"; + relImg.image = bytes; + ImageAsset& absImg = data.addImage().second; + absImg.uri = escapedAbs.string(); + absImg.image = bytes; + + WriteLayerOptions options; + options.assetsPath = outDir.string(); + writeLayer( + options, data, &*layer, sdfData, "Test Data", "Testing", TestFileFormat::SetLayerData); + + // The benign image lands inside assetsPath... + EXPECT_TRUE(fs::exists(outDir / "nested" / "safe.png")); + // ...and neither hostile uri escaped the export directory. + EXPECT_FALSE(fs::exists(escapedRel)) << "\"../\" traversal escaped assetsPath"; + EXPECT_FALSE(fs::exists(escapedAbs)) << "absolute uri escaped assetsPath"; + + // Remove the escape targets too, so a guard regression doesn't leave stray + // files in the temp dir's parent on failure. + fs::remove_all(outDir); + fs::remove(escapedRel); + fs::remove(escapedAbs); +} + #ifdef USD_FILEFORMATS_ENABLE_ASM TEST(FileFormatUtilsTests, writeASM) { @@ -377,9 +708,18 @@ TEST(FileFormatUtilsTests, writeASM) SdfAbstractDataRefPtr sdfData(new SdfData()); UsdData data; - fillGeneralTestMaterial(data); - fillTextureTestMaterial(data); - fillTransmissionMaterial(data); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + if (useOpenPbr) { + fillGeneralTestOpenPbrMaterial(data); + fillTextureTestOpenPbrMaterial(data); + fillScaledNormalTestOpenPbrMaterial(data); + fillTransmissionOpenPbrMaterial(data); + } else { + fillGeneralTestMaterial(data); + fillTextureTestMaterial(data); + fillScaledNormalTestMaterial(data); + fillTransmissionMaterial(data); + } WriteLayerOptions options; options.writeUsdPreviewSurface = false; @@ -402,9 +742,18 @@ TEST(FileFormatUtilsTests, writeOpenPBR) SdfAbstractDataRefPtr sdfData(new SdfData()); UsdData data; - fillGeneralTestMaterial(data); - fillTextureTestMaterial(data); - fillTransmissionMaterial(data); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + if (useOpenPbr) { + fillGeneralTestOpenPbrMaterial(data); + fillTextureTestOpenPbrMaterial(data); + fillScaledNormalTestOpenPbrMaterial(data); + fillTransmissionOpenPbrMaterial(data); + } else { + fillGeneralTestMaterial(data); + fillTextureTestMaterial(data); + fillScaledNormalTestMaterial(data); + fillTransmissionMaterial(data); + } WriteLayerOptions options; options.writeUsdPreviewSurface = false; @@ -420,14 +769,184 @@ TEST(FileFormatUtilsTests, writeOpenPBR) ASSERT_USDA(layer, assetDir + "data/baseline_writeOpenPBR.usda"); } +TEST(FileFormatUtilsTests, writeNodeCustomProperties) +{ + SdfLayerRefPtr layer = SdfLayer::CreateAnonymous("Scene.usda"); + SdfAbstractDataRefPtr sdfData(new SdfData()); + UsdData data; + + auto [rootIdx, root] = data.addNode(-1); // addNode(-1) pushes to rootNodes + root.name = "Root"; + root.customProperties["test:str"] = VtValue(std::string("hello")); + root.customProperties["test:flag"] = VtValue(true); + root.customProperties["test:count"] = VtValue(int(7)); + root.customProperties["test:scale"] = VtValue(2.5f); + root.customProperties["test:vec"] = VtValue(GfVec3f(1.0f, 2.0f, 3.0f)); + root.customProperties["test:arr"] = VtValue(VtArray({ 1, 2, 3 })); + root.customProperties["test:nested"] = + VtValue(VtDictionary{ { "a", VtValue(int(1)) }, { "b", VtValue(int(2)) } }); + + // writeLayer nests UsdData root nodes under a prim named from the layer's display name, + // so "Root" is authored at "/Scene/Root". + WriteLayerOptions options; + writeLayer( + options, data, &*layer, sdfData, "Test Data", "Testing", TestFileFormat::SetLayerData); + + SdfPrimSpecHandle prim = layer->GetPrimAtPath(SdfPath("/Scene/Root")); + ASSERT_TRUE(prim); + VtDictionary customData = prim->GetCustomData(); + + // Each entry lands verbatim in customData: values hold their native type directly (no + // SdfValueTypeName wrapping), and a nested dictionary round-trips intact. + EXPECT_EQ(customData.size(), 7u); + EXPECT_EQ(customData["test:str"], VtValue(std::string("hello"))); + EXPECT_EQ(customData["test:flag"], VtValue(true)); + EXPECT_EQ(customData["test:count"], VtValue(int(7))); + EXPECT_EQ(customData["test:scale"], VtValue(2.5f)); + EXPECT_EQ(customData["test:vec"], VtValue(GfVec3f(1, 2, 3))); + EXPECT_EQ(customData["test:arr"], VtValue(VtArray({ 1, 2, 3 }))); + EXPECT_EQ(customData["test:nested"], + VtValue(VtDictionary{ { "a", VtValue(int(1)) }, { "b", VtValue(int(2)) } })); +} + +TEST(FileFormatUtilsTests, writeNodeCustomPropertiesEmpty) +{ + SdfLayerRefPtr layer = SdfLayer::CreateAnonymous("Scene.usda"); + SdfAbstractDataRefPtr sdfData(new SdfData()); + UsdData data; + + auto [rootIdx, root] = data.addNode(-1); + root.name = "Root"; + // customProperties left empty. + + WriteLayerOptions options; + writeLayer( + options, data, &*layer, sdfData, "Test Data", "Testing", TestFileFormat::SetLayerData); + + SdfPrimSpecHandle prim = layer->GetPrimAtPath(SdfPath("/Scene/Root")); + ASSERT_TRUE(prim); + EXPECT_TRUE(prim->GetCustomData().empty()); +} + +TEST(FileFormatUtilsTests, writeNodeCustomPropertiesAuthorsVerbatim) +{ + SdfLayerRefPtr layer = SdfLayer::CreateAnonymous("Scene.usda"); + SdfAbstractDataRefPtr sdfData(new SdfData()); + UsdData data; + + auto [rootIdx, root] = data.addNode(-1); + root.name = "Root"; + // customData is opaque metadata, not typed attributes, so nothing is filtered: keys that are + // not valid attribute names and empty values are authored as-is. + root.customProperties["test:ok"] = VtValue(true); + root.customProperties["bad name"] = VtValue(int(5)); + root.customProperties["test:empty"] = VtValue(); + + WriteLayerOptions options; + writeLayer( + options, data, &*layer, sdfData, "Test Data", "Testing", TestFileFormat::SetLayerData); + + SdfPrimSpecHandle prim = layer->GetPrimAtPath(SdfPath("/Scene/Root")); + ASSERT_TRUE(prim); + VtDictionary customData = prim->GetCustomData(); + EXPECT_EQ(customData.size(), 3u); + EXPECT_EQ(customData["test:ok"], VtValue(true)); + EXPECT_EQ(customData["bad name"], VtValue(int(5))); + ASSERT_GT(customData.count("test:empty"), 0u) << "empty value should still be authored"; + EXPECT_TRUE(customData["test:empty"].IsEmpty()); +} + +// Guards that writeCustomProperties composes recursively (VtDictionaryOverRecursive), not +// shallowly: overlaying a partial update under a nested key must preserve the untouched siblings +// rather than replacing the whole sub-dictionary. +TEST(SdfUtilsTest, writeCustomPropertiesRecursivelyMergesPreservingSiblings) +{ + SdfAbstractDataRefPtr data(new SdfData()); + createPseudoRootSpec(&*data); + const SdfPath prim = createPrimSpec(&*data, SdfPath::AbsoluteRootPath(), TfToken("Node")); + + // Seed existing customData with a nested sub-dictionary and a top-level sibling. + VtDictionary seed{ { "test", + VtValue(VtDictionary{ { "keep", VtValue(int(1)) }, + { "x", VtValue(std::string("old")) } }) }, + { "otherTop", VtValue(int(9)) } }; + setPrimMetadata(&*data, prim, SdfFieldKeys->CustomData, VtValue(seed)); + + // Overlay a partial update under the same nested key. + writeCustomProperties( + &*data, + prim, + VtDictionary{ { "test", + VtValue(VtDictionary{ { "x", VtValue(std::string("new")) }, + { "y", VtValue(int(2)) } }) } }); + + VtDictionary result; + SdfAbstractDataTypedValue getter(&result); + ASSERT_TRUE(data->Has(prim, SdfFieldKeys->CustomData, &getter)); + + // Top-level sibling survives; "test" remains a dictionary. + EXPECT_EQ(result.size(), 2u); + EXPECT_EQ(result["otherTop"], VtValue(int(9))); + ASSERT_TRUE(result["test"].IsHolding()); + + // Within "test": untouched key preserved, overlapping key overwritten, new key added. A shallow + // merge would have replaced the whole sub-dict, dropping "keep". + VtDictionary nested = result["test"].UncheckedGet(); + EXPECT_EQ(nested.size(), 3u); + EXPECT_EQ(nested["keep"], VtValue(int(1))); + EXPECT_EQ(nested["x"], VtValue(std::string("new"))); + EXPECT_EQ(nested["y"], VtValue(int(2))); +} + +// The single-key convenience setter must accumulate, not clobber: a second write keeps the first. +TEST(SdfUtilsTest, writeCustomPropertySingleKeyPreservesExisting) +{ + SdfAbstractDataRefPtr data(new SdfData()); + createPseudoRootSpec(&*data); + const SdfPath prim = createPrimSpec(&*data, SdfPath::AbsoluteRootPath(), TfToken("Node")); + + writeCustomProperty(&*data, prim, "test:first", VtValue(int(1))); + writeCustomProperty(&*data, prim, "test:second", VtValue(int(2))); + + VtDictionary result; + SdfAbstractDataTypedValue getter(&result); + ASSERT_TRUE(data->Has(prim, SdfFieldKeys->CustomData, &getter)); + + EXPECT_EQ(result.size(), 2u); + EXPECT_EQ(result["test:first"], VtValue(int(1))); + EXPECT_EQ(result["test:second"], VtValue(int(2))); +} + const SdfPath generalTestMaterialPath("/Scene/Materials/GeneralTestMaterial"); const SdfPath textureTestMaterialPath("/Scene/Materials/TextureTestMaterial"); +const SdfPath scaledNormalTestMaterialPath("/Scene/Materials/ScaledNormalTestMaterial"); const SdfPath transmissionTestMaterialPath("/Scene/Materials/TransmissionTestMaterial"); void defaultBaselineProcessor(Material&) {} +// The OpenPBR write path extracts normalScale from the texture reader's scale/bias and passes it +// to ND_normalmap's scale input. The read path reconstructs standard GL decode values, so the +// round-trip normalizes the normal map scale/bias to standard OpenGL convention. +void +normalScaleRoundTripMaterialProcessor(Material& m) +{ + m.normal.scale = kOpenGLNormalTexScale; + m.normal.bias = kOpenGLNormalTexBias; +} + +// Same rationale as normalScaleRoundTripMaterialProcessor, applied to both normal slots of the +// scaled-normal test material. +void +scaledNormalRoundTripMaterialProcessor(Material& m) +{ + m.normal.scale = kOpenGLNormalTexScale; + m.normal.bias = kOpenGLNormalTexBias; + m.clearcoatNormal.scale = kOpenGLNormalTexScale; + m.clearcoatNormal.bias = kOpenGLNormalTexBias; +} + template void readAndCompareMaterial(const UsdStageRefPtr& stage, @@ -461,77 +980,221 @@ readAndCompareMaterial(const UsdStageRefPtr& stage, compareMaterials(material, baselineMaterial, usdData, baselineData); } +void +defaultOpenPbrBaselineProcessor(OpenPbrMaterial&) +{} + +void +normalScaleRoundTripOpenPbrProcessor(OpenPbrMaterial& m) +{ + m.geometry_normal.scale = kOpenGLNormalTexScale; + m.geometry_normal.bias = kOpenGLNormalTexBias; +} + +// Scaled-normal test has scaled-OpenGL on both geometry_normal and geometry_coat_normal. +void +scaledNormalRoundTripOpenPbrProcessor(OpenPbrMaterial& m) +{ + m.geometry_normal.scale = kOpenGLNormalTexScale; + m.geometry_normal.bias = kOpenGLNormalTexBias; + m.geometry_coat_normal.scale = kOpenGLNormalTexScale; + m.geometry_coat_normal.bias = kOpenGLNormalTexBias; +} + +template +void +readAndCompareOpenPbrMaterial(const UsdStageRefPtr& stage, + const SdfPath& materialPath, + BaselineGenerator baselineGenerator, + BaselineProcessor baselineProcessor) +{ + UsdPrim materialPrim = stage->GetPrimAtPath(materialPath); + ASSERT_TRUE(materialPrim); + + UsdData usdData; + + ReadLayerOptions options; + ReadLayerContext ctx; + ctx.stage = stage; + ctx.usd = &usdData; + ctx.options = &options; + ctx.debugTag = "Test"; + ctx.warnAboutMissingAssets = false; + EXPECT_TRUE(readMaterial(ctx, materialPrim)); + + ASSERT_EQ(usdData.openPbrMaterials.size(), 1); + const OpenPbrMaterial& material = usdData.openPbrMaterials[0]; + + UsdData baselineData; + baselineGenerator(baselineData); + ASSERT_EQ(baselineData.openPbrMaterials.size(), 1); + OpenPbrMaterial& baselineMaterial = baselineData.openPbrMaterials[0]; + baselineProcessor(baselineMaterial); + + compareOpenPbrMaterials(material, baselineMaterial, usdData, baselineData); +} + TEST(FileFormatUtilsTests, readUsdPreviewSurface) { - UsdStageRefPtr stage = UsdStage::Open(assetDir + "data/baseline_writeUsdPreviewSurface.usda"); + UsdStageRefPtr stage = openAssetStage(assetDir + "data/baseline_writeUsdPreviewSurface.usda"); ASSERT_TRUE(stage); - auto usdPreviewSurfaceBaselineProcessor = [](Material& baselineMaterial) { - // UsdPreviewSurface doesn't support a couple of inputs, so we clear them - baselineMaterial.specularLevel = {}; - baselineMaterial.normalScale = {}; - baselineMaterial.clearcoatColor = {}; - baselineMaterial.clearcoatIor = {}; - baselineMaterial.clearcoatSpecular = {}; - baselineMaterial.clearcoatNormal = {}; - baselineMaterial.sheenColor = {}; - baselineMaterial.sheenRoughness = {}; - baselineMaterial.anisotropyLevel = {}; - baselineMaterial.anisotropyAngle = {}; - baselineMaterial.transmission = {}; - baselineMaterial.volumeThickness = {}; - baselineMaterial.absorptionDistance = {}; - baselineMaterial.absorptionColor = {}; - baselineMaterial.scatteringDistance = {}; - baselineMaterial.scatteringColor = {}; - baselineMaterial.clearcoatModelsTransmissionTint = false; - baselineMaterial.isUnlit = false; - }; - readAndCompareMaterial( - stage, generalTestMaterialPath, fillGeneralTestMaterial, usdPreviewSurfaceBaselineProcessor); - - readAndCompareMaterial( - stage, textureTestMaterialPath, fillTextureTestMaterial, usdPreviewSurfaceBaselineProcessor); - - readAndCompareMaterial(stage, - transmissionTestMaterialPath, - fillTransmissionMaterialAsUsdPreviewSurfaceBaseline, - usdPreviewSurfaceBaselineProcessor); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + if (useOpenPbr) { + // UsdPreviewSurface doesn't support a number of inputs, so we clear them + // from the baseline before comparison + auto processor = [](OpenPbrMaterial& m) { + m.specular_weight = {}; + m.normalScale = 1.0f; + m.coat_color = {}; + m.coat_ior = {}; + m.coatSpecularLevel = {}; + m.geometry_coat_normal = {}; + m.fuzz_weight = {}; + m.fuzz_color = {}; + m.fuzz_roughness = {}; + m.specular_roughness_anisotropy = {}; + m.anisotropyAngle = {}; + m.transmission_weight = {}; + m.volumeThickness = {}; + m.transmission_depth = {}; + m.transmission_color = {}; + m.subsurface_weight = {}; + m.subsurface_radius = {}; + m.subsurface_color = {}; + m.emission_luminance = {}; + m.clearcoatModelsTransmissionTint = false; + m.isUnlit = false; + }; + readAndCompareOpenPbrMaterial( + stage, generalTestMaterialPath, fillGeneralTestOpenPbrMaterial, processor); + readAndCompareOpenPbrMaterial( + stage, textureTestMaterialPath, fillTextureTestOpenPbrMaterial, processor); + readAndCompareOpenPbrMaterial( + stage, scaledNormalTestMaterialPath, fillScaledNormalTestOpenPbrMaterial, processor); + readAndCompareOpenPbrMaterial(stage, + transmissionTestMaterialPath, + fillTransmissionOpenPbrMaterialAsUsdPreviewSurfaceBaseline, + processor); + } else { + // UsdPreviewSurface doesn't support a number of inputs, so we clear them + // from the baseline before comparison + auto processor = [](Material& baselineMaterial) { + baselineMaterial.specularLevel = {}; + baselineMaterial.normalScale = {}; + baselineMaterial.clearcoatColor = {}; + baselineMaterial.clearcoatIor = {}; + baselineMaterial.clearcoatSpecular = {}; + baselineMaterial.clearcoatNormal = {}; + baselineMaterial.sheenColor = {}; + baselineMaterial.sheenRoughness = {}; + baselineMaterial.anisotropyLevel = {}; + baselineMaterial.anisotropyAngle = {}; + baselineMaterial.transmission = {}; + baselineMaterial.volumeThickness = {}; + baselineMaterial.absorptionDistance = {}; + baselineMaterial.absorptionColor = {}; + baselineMaterial.scatteringDistance = {}; + baselineMaterial.scatteringColor = {}; + baselineMaterial.clearcoatModelsTransmissionTint = false; + baselineMaterial.isUnlit = false; + }; + readAndCompareMaterial(stage, generalTestMaterialPath, fillGeneralTestMaterial, processor); + readAndCompareMaterial(stage, textureTestMaterialPath, fillTextureTestMaterial, processor); + readAndCompareMaterial( + stage, scaledNormalTestMaterialPath, fillScaledNormalTestMaterial, processor); + readAndCompareMaterial(stage, + transmissionTestMaterialPath, + fillTransmissionMaterialAsUsdPreviewSurfaceBaseline, + processor); + } } TEST(FileFormatUtilsTests, readASM) { - UsdStageRefPtr stage = UsdStage::Open(assetDir + "data/baseline_writeASM.usda"); - ASSERT_TRUE(stage); - - readAndCompareMaterial( - stage, generalTestMaterialPath, fillGeneralTestMaterial, defaultBaselineProcessor); + // ASM-specific: this test reads an ASM-network golden USDA. Remove this skip (and the + // test) when ASM is fully retired. + if (!isWriteAsmEnabled()) { + GTEST_SKIP() << "ASM writer is disabled; ASM-specific test will be removed with ASM"; + } - readAndCompareMaterial( - stage, textureTestMaterialPath, fillTextureTestMaterial, defaultBaselineProcessor); + UsdStageRefPtr stage = openAssetStage(assetDir + "data/baseline_writeASM.usda"); + ASSERT_TRUE(stage); - readAndCompareMaterial( - stage, transmissionTestMaterialPath, fillTransmissionMaterial, defaultBaselineProcessor); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + if (useOpenPbr) { + readAndCompareOpenPbrMaterial(stage, + generalTestMaterialPath, + fillGeneralTestOpenPbrMaterial, + defaultOpenPbrBaselineProcessor); + readAndCompareOpenPbrMaterial(stage, + textureTestMaterialPath, + fillTextureTestOpenPbrMaterial, + defaultOpenPbrBaselineProcessor); + readAndCompareOpenPbrMaterial(stage, + scaledNormalTestMaterialPath, + fillScaledNormalTestOpenPbrMaterial, + defaultOpenPbrBaselineProcessor); + readAndCompareOpenPbrMaterial(stage, + transmissionTestMaterialPath, + fillTransmissionOpenPbrMaterial, + defaultOpenPbrBaselineProcessor); + } else { + readAndCompareMaterial( + stage, generalTestMaterialPath, fillGeneralTestMaterial, defaultBaselineProcessor); + readAndCompareMaterial( + stage, textureTestMaterialPath, fillTextureTestMaterial, defaultBaselineProcessor); + readAndCompareMaterial(stage, + scaledNormalTestMaterialPath, + fillScaledNormalTestMaterial, + defaultBaselineProcessor); + readAndCompareMaterial( + stage, transmissionTestMaterialPath, fillTransmissionMaterial, defaultBaselineProcessor); + } } TEST(FileFormatUtilsTests, readOpenPBR) { - UsdStageRefPtr stage = UsdStage::Open(assetDir + "data/baseline_writeOpenPBR.usda"); + UsdStageRefPtr stage = openAssetStage(assetDir + "data/baseline_writeOpenPBR.usda"); ASSERT_TRUE(stage); - readAndCompareMaterial( - stage, generalTestMaterialPath, fillGeneralTestMaterial, defaultBaselineProcessor); - - readAndCompareMaterial( - stage, textureTestMaterialPath, fillTextureTestMaterial, defaultBaselineProcessor); - - readAndCompareMaterial( - stage, transmissionTestMaterialPath, fillTransmissionMaterial, defaultBaselineProcessor); + const bool useOpenPbr = isNativeOpenPbrProcessingEnabled(); + if (useOpenPbr) { + readAndCompareOpenPbrMaterial(stage, + generalTestMaterialPath, + fillGeneralTestOpenPbrMaterial, + defaultOpenPbrBaselineProcessor); + readAndCompareOpenPbrMaterial(stage, + textureTestMaterialPath, + fillTextureTestOpenPbrMaterial, + normalScaleRoundTripOpenPbrProcessor); + readAndCompareOpenPbrMaterial(stage, + scaledNormalTestMaterialPath, + fillScaledNormalTestOpenPbrMaterial, + scaledNormalRoundTripOpenPbrProcessor); + readAndCompareOpenPbrMaterial(stage, + transmissionTestMaterialPath, + fillTransmissionOpenPbrMaterial, + defaultOpenPbrBaselineProcessor); + } else { + readAndCompareMaterial( + stage, generalTestMaterialPath, fillGeneralTestMaterial, defaultBaselineProcessor); + readAndCompareMaterial(stage, + textureTestMaterialPath, + fillTextureTestMaterial, + normalScaleRoundTripMaterialProcessor); + readAndCompareMaterial(stage, + scaledNormalTestMaterialPath, + fillScaledNormalTestMaterial, + scaledNormalRoundTripMaterialProcessor); + readAndCompareMaterial( + stage, transmissionTestMaterialPath, fillTransmissionMaterial, defaultBaselineProcessor); + } } TEST(FileFormatUtilsTests, invalidNetworkReading) { - UsdStageRefPtr stage = UsdStage::Open(assetDir + "data/test_invalidNetworks.usda"); + UsdStageRefPtr stage = openAssetStage(assetDir + "data/test_invalidNetworks.usda"); ASSERT_TRUE(stage); UsdPrim materials = stage->GetPrimAtPath(SdfPath("/Scene/Materials")); @@ -557,6 +1220,596 @@ TEST(FileFormatUtilsTests, invalidNetworkReading) // XXX This is worth debating, whether or not we should have a partial material in the scene // We current continue with a partial material - ASSERT_EQ(usdData.materials.size(), 1); + if (isNativeOpenPbrProcessingEnabled()) { + ASSERT_EQ(usdData.openPbrMaterials.size(), 1); + } else { + ASSERT_EQ(usdData.materials.size(), 1); + } + } +} + +namespace { +int +countWarningsMentioning(const std::vector& warnings, const std::string& needle) +{ + int count = 0; + for (const auto& w : warnings) { + if (w.find(needle) != std::string::npos) { + ++count; + } + } + return count; +} +} + +TEST(FileFormatUtilsTests, deprecatedMaterialSettingsWarnOncePerProcess) +{ + resetDeprecationWarningOnceFlagsForTesting(); + + UsdDiagnosticDelegate delegate; + + // Three calls with both deprecated settings enabled — expect exactly one + // warning per setting regardless of call count. + warnOnceOnDeprecatedMaterialSettings(/*writeASM*/ true, /*writeUsdPreviewSurface*/ true); + warnOnceOnDeprecatedMaterialSettings(/*writeASM*/ true, /*writeUsdPreviewSurface*/ true); + warnOnceOnDeprecatedMaterialSettings(/*writeASM*/ true, /*writeUsdPreviewSurface*/ true); + + EXPECT_EQ(countWarningsMentioning(delegate.GetWarnings(), "USD_FILEFORMATS_WRITE_ASM"), 1); + EXPECT_EQ( + countWarningsMentioning(delegate.GetWarnings(), "USD_FILEFORMATS_WRITE_USDPREVIEWSURFACE"), + 1); + + // Each warning should tell the user how to suppress it by setting the deprecated + // flag to 0. + for (const auto& w : delegate.GetWarnings()) { + EXPECT_NE(w.find("=0"), std::string::npos); + } + + // After reset, a call with both settings false must stay silent. + resetDeprecationWarningOnceFlagsForTesting(); + UsdDiagnosticDelegate silent; + warnOnceOnDeprecatedMaterialSettings(/*writeASM*/ false, /*writeUsdPreviewSurface*/ false); + EXPECT_EQ(countWarningsMentioning(silent.GetWarnings(), "USD_FILEFORMATS_WRITE_ASM"), 0); + EXPECT_EQ( + countWarningsMentioning(silent.GetWarnings(), "USD_FILEFORMATS_WRITE_USDPREVIEWSURFACE"), 0); +} + +/// InputTranslator tests ////////////////////////////////////////////////////////////////////////// + +// Verify translateMax with constant scalar inputs returns the per-element maximum. +TEST(InputTranslatorTests, TranslateMaxConstantScalar) +{ + std::vector images; + InputTranslator translator(/*exportImages=*/false, images, "test"); + + Input a, b, out; + a.value = VtValue(0.3f); + b.value = VtValue(0.7f); + + ASSERT_TRUE(translator.translateMax("ab", a, b, out)); + ASSERT_TRUE(out.value.IsHolding()); + EXPECT_FLOAT_EQ(out.value.UncheckedGet(), 0.7f); + + // Swap inputs — result is the same + ASSERT_TRUE(translator.translateMax("ba", b, a, out)); + ASSERT_TRUE(out.value.IsHolding()); + EXPECT_FLOAT_EQ(out.value.UncheckedGet(), 0.7f); + + // Equal inputs + ASSERT_TRUE(translator.translateMax("aa", a, a, out)); + EXPECT_FLOAT_EQ(out.value.UncheckedGet(), 0.3f); + + // Empty input must return false + Input empty; + EXPECT_FALSE(translator.translateMax("emptyLeft", empty, a, out)); + EXPECT_FALSE(translator.translateMax("emptyRight", a, empty, out)); +} + +// Verify translateMax with constant GfVec3f inputs operates per-channel. +TEST(InputTranslatorTests, TranslateMaxConstantVec3) +{ + std::vector images; + InputTranslator translator(/*exportImages=*/false, images, "test"); + + Input a, b, out; + a.value = VtValue(GfVec3f(0.1f, 0.8f, 0.3f)); + b.value = VtValue(GfVec3f(0.5f, 0.2f, 0.9f)); + + ASSERT_TRUE(translator.translateMax("vec3", a, b, out)); + ASSERT_TRUE(out.value.IsHolding()); + GfVec3f result = out.value.UncheckedGet(); + EXPECT_FLOAT_EQ(result[0], 0.5f); // max(0.1, 0.5) + EXPECT_FLOAT_EQ(result[1], 0.8f); // max(0.8, 0.2) + EXPECT_FLOAT_EQ(result[2], 0.9f); // max(0.3, 0.9) +} + +// Verify translateMax with image inputs produces the correct per-pixel per-channel maximum. +// Images are injected as intermediates (decoded pixels in mImagesSrc) so no PNG encoding +// is needed on the input side; only the output is encoded. +TEST(InputTranslatorTests, TranslateMaxImages) +{ + std::vector images; + InputTranslator translator(/*exportImages=*/true, images, "test"); + + // Image A: 2×2 RGB, every pixel = (0.2, 0.8, 0.4) + // Image B: 2×2 RGB, every pixel = (0.6, 0.3, 0.7) + // Expected max: (0.6, 0.8, 0.7) at every pixel + Image imgA, imgB; + imgA.allocate(2, 2, 3); + imgB.allocate(2, 2, 3); + for (int i = 0; i < 4; ++i) { + imgA.pixels[i * 3 + 0] = 0.2f; + imgA.pixels[i * 3 + 1] = 0.8f; + imgA.pixels[i * 3 + 2] = 0.4f; + imgB.pixels[i * 3 + 0] = 0.6f; + imgB.pixels[i * 3 + 1] = 0.3f; + imgB.pixels[i * 3 + 2] = 0.7f; + } + + Input inA, inB; + inA.image = translator.addImage( + std::move(imgA), "imgA", "imgA.png", ImageFormatPng, /*intermediate=*/true); + inA.channel = AdobeTokens->rgb; + inB.image = translator.addImage( + std::move(imgB), "imgB", "imgB.png", ImageFormatPng, /*intermediate=*/true); + inB.channel = AdobeTokens->rgb; + + Input out; + ASSERT_TRUE(translator.translateMax("maxAB", inA, inB, out)); + ASSERT_GE(out.image, 0); + + // Only the final output image should appear in getImages() — no intermediates + const std::vector& outImages = translator.getImages(); + ASSERT_EQ(outImages.size(), 1u); + + // Decode the output PNG and verify per-pixel values (PNG round-trip introduces small error) + Image decoded; + ASSERT_TRUE(decoded.read(outImages[0])); + ASSERT_EQ(decoded.width, 2); + ASSERT_EQ(decoded.height, 2); + ASSERT_EQ(decoded.channels, 3); + + constexpr float kTol = 0.01f; // PNG is 8-bit, so ~0.004 quantization error + for (int i = 0; i < 4; ++i) { + EXPECT_NEAR(decoded.pixels[i * 3 + 0], 0.6f, kTol) << "pixel " << i << " R"; + EXPECT_NEAR(decoded.pixels[i * 3 + 1], 0.8f, kTol) << "pixel " << i << " G"; + EXPECT_NEAR(decoded.pixels[i * 3 + 2], 0.7f, kTol) << "pixel " << i << " B"; + } +} + +// Verify that intermediate=true results are stored inside the translator and can be +// consumed by a subsequent operation without appearing in getImages(). +// +// Pipeline under test: +// src (0.5, 0.5, 0.5) ─── translateProduct(×0.8) [intermediate] ──► (0.4, 0.4, 0.4) +// │ +// translateMax(0.5) ▼ +// (0.5, 0.5, 0.5) [final] +TEST(InputTranslatorTests, TranslateIntermediateChaining) +{ + std::vector images; + InputTranslator translator(/*exportImages=*/true, images, "test"); + + // Source image: 1×1 RGB, all channels = 0.5 + Image srcImg; + srcImg.allocate(1, 1, 3); + srcImg.pixels = { 0.5f, 0.5f, 0.5f }; + + Input src; + src.image = translator.addImage( + std::move(srcImg), "src", "src.png", ImageFormatPng, /*intermediate=*/true); + src.channel = AdobeTokens->rgb; + + // Step 1 (intermediate): multiply by 0.8 → (0.4, 0.4, 0.4), stored in mImagesSrc only + Input factor; + factor.value = VtValue(GfVec3f(0.8f, 0.8f, 0.8f)); + Input step1; + ASSERT_TRUE(translator.translateProduct("step1", src, factor, step1, /*intermediate=*/true)); + EXPECT_GE(step1.image, 0); + + // Step 2 (final): max(step1, 0.5) → per-channel max(0.4, 0.5) = 0.5, stored in mImagesDst + Input floor; + floor.value = VtValue(GfVec3f(0.5f, 0.5f, 0.5f)); + Input finalOut; + ASSERT_TRUE(translator.translateMax("step2", step1, floor, finalOut)); + EXPECT_GE(finalOut.image, 0); + + // Only the non-intermediate final image should be in getImages() + const std::vector& outImages = translator.getImages(); + ASSERT_EQ(outImages.size(), 1u) << "Intermediate image must not appear in getImages()"; + + // Verify pixel values: max(0.4, 0.5) = 0.5 per channel + Image decoded; + ASSERT_TRUE(decoded.read(outImages[0])); + constexpr float kTol = 0.01f; + EXPECT_NEAR(decoded.pixels[0], 0.5f, kTol) << "R"; + EXPECT_NEAR(decoded.pixels[1], 0.5f, kTol) << "G"; + EXPECT_NEAR(decoded.pixels[2], 0.5f, kTol) << "B"; +} + +// Encode an Image into a source ImageAsset (with real encoded bytes) so that the phong-to-PBR +// bake path, which re-reads source textures from the encoded byte stream, can decode it. +static ImageAsset +makeSourceImage(const Image& image, const std::string& name) +{ + ImageAsset asset; + asset.name = name; + asset.uri = name; + asset.format = ImageFormatPng; + image.write(asset); + return asset; +} + +// A Phong material with a specular and/or glossiness texture but no diffuse texture must +// convert to PBR without aborting. The empty diffuse component is substituted with a default, +// and the generated diffuse/metallic/roughness textures inherit the specular/gloss dimensions. +TEST(InputTranslatorTests, Phong2PBRNoDiffuseTexture) +{ + // Specular: 2×2 RGB, glossiness: 2×2 single-channel. No diffuse texture. + Image specularImg, glossImg; + specularImg.allocate(2, 2, 3); + glossImg.allocate(2, 2, 1); + for (int i = 0; i < 4; ++i) { + specularImg.pixels[i * 3 + 0] = 0.4f; + specularImg.pixels[i * 3 + 1] = 0.4f; + specularImg.pixels[i * 3 + 2] = 0.4f; + glossImg.pixels[i] = 0.6f; + } + + std::vector images; + images.push_back(makeSourceImage(specularImg, "spec.png")); + images.push_back(makeSourceImage(glossImg, "gloss.png")); + InputTranslator translator(/*exportImages=*/true, images, "test"); + + Input diffuseIn; // absent: image == -1, value empty + Input specularIn; + specularIn.image = 0; + specularIn.channel = AdobeTokens->rgb; + Input glossIn; + glossIn.image = 1; + glossIn.channel = AdobeTokens->r; + + Input diffuseOut, metallicOut, roughnessOut; + ASSERT_TRUE(translator.translatePhong2PBR( + diffuseIn, specularIn, glossIn, diffuseOut, metallicOut, roughnessOut)); + + // All three outputs are baked textures dimensioned from the specular/gloss inputs. + ASSERT_GE(diffuseOut.image, 0); + ASSERT_GE(metallicOut.image, 0); + ASSERT_GE(roughnessOut.image, 0); + + const std::vector& outImages = translator.getImages(); + Image decodedDiffuse; + ASSERT_TRUE(decodedDiffuse.read(outImages[diffuseOut.image])); + EXPECT_EQ(decodedDiffuse.width, 2); + EXPECT_EQ(decodedDiffuse.height, 2); + EXPECT_FALSE(decodedDiffuse.pixels.empty()); +} + +// Same conversion must succeed when only a glossiness texture is present (both diffuse and +// specular source textures absent), exercising the absent-specular handling. +TEST(InputTranslatorTests, Phong2PBROnlyGlossTexture) +{ + Image glossImg; + glossImg.allocate(2, 2, 1); + for (int i = 0; i < 4; ++i) + glossImg.pixels[i] = 0.7f; + + std::vector images; + images.push_back(makeSourceImage(glossImg, "gloss.png")); + InputTranslator translator(/*exportImages=*/true, images, "test"); + + Input diffuseIn; // absent + Input specularIn; // absent + Input glossIn; + glossIn.image = 0; + glossIn.channel = AdobeTokens->r; + + Input diffuseOut, metallicOut, roughnessOut; + ASSERT_TRUE(translator.translatePhong2PBR( + diffuseIn, specularIn, glossIn, diffuseOut, metallicOut, roughnessOut)); + + ASSERT_GE(roughnessOut.image, 0); + const std::vector& outImages = translator.getImages(); + Image decodedRoughness; + ASSERT_TRUE(decodedRoughness.read(outImages[roughnessOut.image])); + EXPECT_EQ(decodedRoughness.width, 2); + EXPECT_EQ(decodedRoughness.height, 2); + EXPECT_FALSE(decodedRoughness.pixels.empty()); +} + +TEST(NamingTest, MakeValidUsdIdentifier) +{ + using adobe::usd::MakeValidUsdIdentifier; + + // Punctuation, mixed script, diacritics, leading digit, emoji, and empty input. + EXPECT_EQ(MakeValidUsdIdentifier("Object_n3d#"), "Object_n3d_"); + EXPECT_EQ(MakeValidUsdIdentifier("京都 Building"), "京都_Building"); + EXPECT_EQ(MakeValidUsdIdentifier("Müller café"), "Müller_café"); + EXPECT_EQ(MakeValidUsdIdentifier("2024_render"), "_2024_render"); + EXPECT_EQ(MakeValidUsdIdentifier("🎨 art"), "__art"); + EXPECT_EQ(MakeValidUsdIdentifier(""), "_"); + + // Source-name pattern catalog (foreign-format imports, locale-baked + // _display_name defaults, rename-UI input) — single-script cases pass + // through unchanged because every codepoint is XID_Start/Continue. + EXPECT_EQ(MakeValidUsdIdentifier("カメラ"), "カメラ"); // ja-JP + EXPECT_EQ(MakeValidUsdIdentifier("카메라"), "카메라"); // ko-KR + EXPECT_EQ(MakeValidUsdIdentifier("相机"), "相机"); // zh-CN + EXPECT_EQ(MakeValidUsdIdentifier("Москва"), "Москва"); // ru-RU Cyrillic + EXPECT_EQ(MakeValidUsdIdentifier("Δέλτα"), "Δέλτα"); // Greek + EXPECT_EQ(MakeValidUsdIdentifier("Окружающая среда"), "Окружающая_среда"); // space -> _ + EXPECT_EQ(MakeValidUsdIdentifier("Standardkamera 1"), "Standardkamera_1"); // de-DE + + // ASCII edge cases: separators, punctuation, parentheses, leading digit. + EXPECT_EQ(MakeValidUsdIdentifier("MyObject"), "MyObject"); + EXPECT_EQ(MakeValidUsdIdentifier("My Object"), "My_Object"); + EXPECT_EQ(MakeValidUsdIdentifier("Object-1"), "Object_1"); + EXPECT_EQ(MakeValidUsdIdentifier("Object.1"), "Object_1"); + EXPECT_EQ(MakeValidUsdIdentifier("Object (copy)"), "Object__copy_"); + EXPECT_EQ(MakeValidUsdIdentifier("2024_render-2"), "_2024_render_2"); + + // Output of every call is itself a valid USD identifier. + EXPECT_TRUE(pxr::SdfPath::IsValidIdentifier(MakeValidUsdIdentifier("🎨 art"))); + EXPECT_TRUE(pxr::SdfPath::IsValidIdentifier(MakeValidUsdIdentifier("Müller café"))); +} + +// Verify that uniquifyNames resolves prim-name collisions across all sibling groups that land +// under the same parent Xform in USD: camera, light, meshes (including instanceable), curves, +// and child nodes. Prior to the fix, each geometry group was uniquified in its own private +// namespace, and camera/light were not uniquified against geometry at all, so cross-group +// name collisions could trigger TypeName-overwrite corruption at write time. +TEST(NamingTest, UniquifyNodeSharedSiblingNamespace) +{ + using adobe::usd::uniquifyNames; + + UsdData data; + + // Root node that owns all the siblings under test. + // addNode(-1) automatically pushes to rootNodes, so no manual push needed. + auto [rootIdx, root] = data.addNode(-1); + + // Camera and light named "Shape" — written before geometry in _writeNode, so they are + // seeded into the shared namespace first. + auto [camIdx, cam] = data.addCamera(); + cam.name = "Shape"; + root.camera = camIdx; + + auto [lightIdx, light] = data.addLight(); + light.name = "Shape"; + root.light = lightIdx; + + // Mesh, curve, child node, and instanceable mesh all named "Shape" — must each resolve + // to a distinct prim name that doesn't collide with camera or light either. + auto [meshIdx, mesh] = data.addMesh(); + mesh.name = "Shape"; + root.staticMeshes.push_back(meshIdx); + + auto [curveIdx, curve] = data.addCurve(); + curve.name = "Shape"; + root.curves.push_back(curveIdx); + + // addNode may resize data.nodes, invalidating `root`. Use data.nodes[rootIdx] after this + // point. addNode also automatically adds childIdx to nodes[rootIdx].children internally. + auto [childIdx, child] = data.addNode(rootIdx); + child.name = "Shape"; + child.displayName = "Shape"; + + auto [instMeshIdx, instMesh] = data.addMesh(); + instMesh.name = "Shape"; + instMesh.instanceable = true; + data.nodes[rootIdx].staticMeshes.push_back(instMeshIdx); + + uniquifyNames(data); + + const std::string& camName = data.cameras[camIdx].name; + const std::string& lightName = data.lights[lightIdx].name; + const std::string& meshName = data.meshes[meshIdx].name; + const std::string& curveName = data.curves[curveIdx].name; + const std::string& childName = data.nodes[childIdx].name; + const std::string& instMeshName = data.meshes[instMeshIdx].name; + + // All six siblings must have distinct prim names. + std::vector names = { camName, lightName, meshName, + curveName, childName, instMeshName }; + for (size_t i = 0; i < names.size(); ++i) { + for (size_t j = i + 1; j < names.size(); ++j) { + EXPECT_NE(names[i], names[j]) << "collision between sibling " << i << " and " << j; + } } + + // All names must be valid USD identifiers. + for (const std::string& name : names) { + EXPECT_TRUE(pxr::SdfPath::IsValidIdentifier(name)) << "invalid identifier: " << name; + } + + // The child node originally had a displayName — verify it is updated consistently with + // the renamed prim name (either cleared because names match, or preserved as original). + const std::string& childDisplayName = data.nodes[childIdx].displayName; + EXPECT_TRUE(childDisplayName.empty() || childDisplayName != childName); +} + +// A node named "Materials" must not collide with the "Materials" scope the writer synthesizes +// under the root when materials exist. A duplicate child name is not validated when writing USDA +// (the two defs merge), but USDC rejects it (crateData enforces unique primChildren). This can +// arise whenever an imported scene already contains a direct child of the root named "Materials". +TEST(NamingTest, MaterialsScopeNodeCollision) +{ + SdfLayerRefPtr layer = SdfLayer::CreateAnonymous("Scene.usda"); + SdfAbstractDataRefPtr sdfData(new SdfData()); + + UsdData data; + // Root node whose name collides with the synthesized "Materials" scope. addNode(-1) pushes + // to rootNodes automatically. + auto& root = data.addNode(-1).second; + root.name = "Materials"; + root.displayName = "Materials"; + // Any material makes usdData.materials non-empty, so the writer creates the "Materials" scope. + data.addMaterial().second.name = "SomeMaterial"; + + WriteLayerOptions options; + writeLayer( + options, data, &*layer, sdfData, "Test Data", "Testing", TestFileFormat::SetLayerData); + + // Find the single root prim without hardcoding its name (derived from the layer stem). + auto roots = layer->GetRootPrims(); + ASSERT_EQ(roots.size(), 1u); + const SdfPath rootPath = roots[0]->GetPath(); + + // The synthesized scope and the colliding node must be distinct prims. + EXPECT_TRUE(layer->GetPrimAtPath(rootPath.AppendChild(TfToken("Materials")))) + << "synthesized Materials scope missing"; + EXPECT_TRUE(layer->GetPrimAtPath(rootPath.AppendChild(TfToken("Materials1")))) + << "colliding node was not uniquified to Materials1"; + + // End-to-end: the layer must serialize to USDC, whose crate format enforces unique + // primChildren. ArchMakeTmpFileName gives a pid-unique path so concurrent test runs don't + // collide; cleanup is best-effort via the non-throwing std::error_code overload. + const std::string usdcPath = ArchMakeTmpFileName("materials_collision_test", ".usdc"); + EXPECT_TRUE(layer->Export(usdcPath)) << "USDC export failed -- duplicate prim children"; + std::error_code ec; + std::filesystem::remove(usdcPath, ec); +} + +// A duplicate child name under one parent isn't caught when writing USDA, but USDC rejects it +// (duplicate primChildren). Authoring one must post a coding error. +TEST(SdfUtilsTest, DuplicateChildPrimPostsCodingError) +{ + SdfAbstractDataRefPtr data(new SdfData()); + createPseudoRootSpec(&*data); + const SdfPath root = SdfPath::AbsoluteRootPath(); + + // First child under the root is fine, no error. + createPrimSpec(&*data, root, TfToken("Materials")); + + // Adding the same name via the batched node path must be flagged. + { + TfErrorMark mark; + appendToChildList(&*data, root, { TfToken("Materials") }); + EXPECT_FALSE(mark.IsClean()) << "duplicate child via appendToChildList was not flagged"; + mark.Clear(); + } + + // Adding the same name via the single-append path (createPrimSpec, append=true) must be too. + { + TfErrorMark mark; + createPrimSpec(&*data, root, TfToken("Materials")); + EXPECT_FALSE(mark.IsClean()) << "duplicate child via createPrimSpec was not flagged"; + mark.Clear(); + } +} + +// Maps each supported image MIME spelling (including format aliases) to its +// ImageFormat, and confirms unmapped/empty spellings fall back to Unknown. +TEST(FileFormatUtilsTests, getFormatFromMimeType) +{ + // Already-mapped MIME types stay correct. + EXPECT_EQ(getFormatFromMimeType("image/png"), ImageFormatPng); + EXPECT_EQ(getFormatFromMimeType("image/jpeg"), ImageFormatJpg); + EXPECT_EQ(getFormatFromMimeType("image/jpg"), ImageFormatJpg); + EXPECT_EQ(getFormatFromMimeType("image/x-exr"), ImageFormatExr); + EXPECT_EQ(getFormatFromMimeType("image/exr"), ImageFormatExr); + EXPECT_EQ(getFormatFromMimeType("image/vnd.radiance"), ImageFormatHdr); + EXPECT_EQ(getFormatFromMimeType("image/vnd.adobe.photoshop"), ImageFormatPsd); + EXPECT_EQ(getFormatFromMimeType("image/tiff"), ImageFormatTiff); + + // Newly-mapped primaries. + EXPECT_EQ(getFormatFromMimeType("image/bmp"), ImageFormatBmp); + EXPECT_EQ(getFormatFromMimeType("image/tga"), ImageFormatTga); + EXPECT_EQ(getFormatFromMimeType("image/webp"), ImageFormatWebp); + EXPECT_EQ(getFormatFromMimeType("image/tif"), ImageFormatTiff); + EXPECT_EQ(getFormatFromMimeType("image/hdr"), ImageFormatHdr); + + // Alias MIME types, including application/* for representable formats. + EXPECT_EQ(getFormatFromMimeType("image/x-windows-bmp"), ImageFormatBmp); + EXPECT_EQ(getFormatFromMimeType("image/x-ms-bmp"), ImageFormatBmp); + EXPECT_EQ(getFormatFromMimeType("image/x-targa"), ImageFormatTga); + EXPECT_EQ(getFormatFromMimeType("application/x-targa"), ImageFormatTga); + EXPECT_EQ(getFormatFromMimeType("application/x-tga"), ImageFormatTga); + EXPECT_EQ(getFormatFromMimeType("image/x-photoshop"), ImageFormatPsd); + EXPECT_EQ(getFormatFromMimeType("application/x-photoshop"), ImageFormatPsd); + + // Noted gaps and unknown input degrade to Unknown (no ImageFormat; the resolver + // still preserves image/* bytes via its prefix gate). + EXPECT_EQ(getFormatFromMimeType("application/postscript"), ImageFormatUnknown); + EXPECT_EQ(getFormatFromMimeType("application/vnd.adobe.illustrator"), ImageFormatUnknown); + EXPECT_EQ(getFormatFromMimeType("image/gif"), ImageFormatUnknown); + EXPECT_EQ(getFormatFromMimeType("image/svg+xml"), ImageFormatUnknown); + EXPECT_EQ(getFormatFromMimeType("application/octet-stream"), ImageFormatUnknown); + EXPECT_EQ(getFormatFromMimeType("model/gltf-binary"), ImageFormatUnknown); + EXPECT_EQ(getFormatFromMimeType(""), ImageFormatUnknown); + + // Radiance HDR is new to the enum; confirm the extension pair round-trips. + EXPECT_EQ(getFormat("hdr"), ImageFormatHdr); + EXPECT_EQ(getFormatExtension(ImageFormatHdr), "hdr"); +} + +/// Image tests //////////////////////////////////////////////////////////////////////////////// + +// Image::allocate() must reject a width*height*channels product that overflows a +// signed 32-bit int instead of silently wrapping and under-allocating pixels. +TEST(ImageTests, AllocateRejectsOverflowingDimensions) +{ + Image image; + EXPECT_FALSE(image.allocate(65536, 65536, 4)); + EXPECT_EQ(image.width, 0); + EXPECT_EQ(image.height, 0); + EXPECT_EQ(image.channels, 0); + EXPECT_TRUE(image.pixels.empty()); +} + +TEST(ImageTests, AllocateRejectsInvalidDimensions) +{ + Image negativeWidth; + EXPECT_FALSE(negativeWidth.allocate(-1, 4, 4)); + EXPECT_TRUE(negativeWidth.pixels.empty()); + + Image zeroHeight; + EXPECT_FALSE(zeroHeight.allocate(4, 0, 4)); + EXPECT_TRUE(zeroHeight.pixels.empty()); + + Image zeroChannels; + EXPECT_FALSE(zeroChannels.allocate(4, 4, 0)); + EXPECT_TRUE(zeroChannels.pixels.empty()); +} + +// Regression: a normal small image still allocates, transforms, and round-trips through +// write()/read() correctly. read() shares allocate()'s validation helper, so a valid decode here +// also exercises the same guard on the read() path. +TEST(ImageTests, AllocateAndTransformValidDimensions) +{ + Image src; + ASSERT_TRUE(src.allocate(4, 4, 4)); + EXPECT_EQ(src.pixels.size(), 4u * 4u * 4u); + src.set(0.1f, 0.2f, 0.3f, 0.4f); + + Image dst; + ASSERT_TRUE(dst.allocate(4, 4, 4)); + EXPECT_TRUE(dst.copyChannel(src, 0, 0)); + EXPECT_FLOAT_EQ(dst.pixels[0], 0.1f); + + ImageAsset asset; + asset.format = ImageFormatPng; + ASSERT_TRUE(src.write(asset)); + Image decoded; + ASSERT_TRUE(decoded.read(asset)); + EXPECT_EQ(decoded.width, 4); + EXPECT_EQ(decoded.height, 4); + EXPECT_EQ(decoded.channels, 4); +} + +// A rejected allocate() must leave dimensions/pixels in a consistent empty state, so that +// downstream calls which don't check the bool return (transformChannel/set) stay no-ops instead +// of indexing into an empty buffer using stale, oversized dimensions. +TEST(ImageTests, RejectedAllocateStaysSafeForDownstreamOps) +{ + Image src; + ASSERT_TRUE(src.allocate(4, 4, 4)); + src.set(1.0f, 1.0f, 1.0f, 1.0f); + + Image dst; + EXPECT_FALSE(dst.allocate(65536, 65536, 4)); + + EXPECT_FALSE(dst.transformChannel(src, 0, 1.0f, 0.0f, 0)); + dst.set(1.0f, 1.0f, 1.0f, 1.0f); // must not crash despite the earlier oversized request } diff --git a/version.json b/version.json index 40408be0..fec46a6e 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "2026.05" + "version": "2026.07" }