diff --git a/CMakeLists.txt b/CMakeLists.txt index 720ff12f90..35321c80c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -287,6 +287,9 @@ set(FILAMENT ${CMAKE_CURRENT_SOURCE_DIR}) # Where our tools are set(TOOLS ${CMAKE_CURRENT_SOURCE_DIR}/tools) +# Where our tools are +set(TESTS ${CMAKE_CURRENT_SOURCE_DIR}/test) + # ================================================================================================== # Compiler check # ================================================================================================== @@ -896,6 +899,8 @@ if (IS_HOST_PLATFORM) add_subdirectory(${TOOLS}/roughness-prefilter) add_subdirectory(${TOOLS}/specular-color) add_subdirectory(${TOOLS}/uberz) + + add_subdirectory(${TESTS}/gltf-comparison) endif() # Generate exported executables for cross-compiled builds (Android, WebGL, and iOS) diff --git a/filament/backend/test/ImageExpectations.cpp b/filament/backend/test/ImageExpectations.cpp index 5937ac7f92..84305d72d1 100644 --- a/filament/backend/test/ImageExpectations.cpp +++ b/filament/backend/test/ImageExpectations.cpp @@ -21,9 +21,9 @@ #include "utils/Hash.h" #include -#include "BackendTest.h" +//#include "BackendTest.h" #include "backend/PixelBufferDescriptor.h" -#include "private/backend/DriverApi.h" +//#include "private/backend/DriverApi.h" #ifndef FILAMENT_IOS diff --git a/test/gltf-comparison/CMakeLists.txt b/test/gltf-comparison/CMakeLists.txt new file mode 100644 index 0000000000..583b415997 --- /dev/null +++ b/test/gltf-comparison/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.19) +project(filament C ASM) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(GLTF_COMPARISON_SOURCES + src/test_CompareGLTF.cpp + src/GLTFViewer.cpp + src/ImageExpectations.cpp + src/main.cpp + ) + +file(COPY glTF_cases DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/glTF_cases) +file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/images/actual_images) +file(COPY expected_images DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/images) + +enable_testing() + +add_executable(gltf_comparison ${GLTF_COMPARISON_SOURCES}) +target_include_directories(gltf_comparison PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(gltf_comparison + gtest + filamentapp + gltfio + absl::str_format +) + + + +include(GoogleTest) +gtest_discover_tests(gltf_comparison) \ No newline at end of file diff --git a/test/gltf-comparison/expected_images/GLTF.png b/test/gltf-comparison/expected_images/GLTF.png new file mode 100644 index 0000000000..efda3e4b58 Binary files /dev/null and b/test/gltf-comparison/expected_images/GLTF.png differ diff --git a/test/gltf-comparison/glTF_cases/Models/Box/LICENSE.md b/test/gltf-comparison/glTF_cases/Models/Box/LICENSE.md new file mode 100644 index 0000000000..84787839a2 --- /dev/null +++ b/test/gltf-comparison/glTF_cases/Models/Box/LICENSE.md @@ -0,0 +1,15 @@ +# LICENSE file for the model: Box + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [CC BY 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribtution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above + +#### Generated by modelmetadata \ No newline at end of file diff --git a/test/gltf-comparison/glTF_cases/Models/Box/README.body.md b/test/gltf-comparison/glTF_cases/Models/Box/README.body.md new file mode 100644 index 0000000000..2e55a1245d --- /dev/null +++ b/test/gltf-comparison/glTF_cases/Models/Box/README.body.md @@ -0,0 +1,7 @@ +## Screenshot + +![screenshot](screenshot/screenshot.png) + +## Description + +Simple cube model. \ No newline at end of file diff --git a/test/gltf-comparison/glTF_cases/Models/Box/README.md b/test/gltf-comparison/glTF_cases/Models/Box/README.md new file mode 100644 index 0000000000..3a19b27978 --- /dev/null +++ b/test/gltf-comparison/glTF_cases/Models/Box/README.md @@ -0,0 +1,31 @@ +# Box + +## Tags + +[core](../../Models-core.md), [testing](../../Models-testing.md) + +## Summary + +One mesh and one material. Start with this. + +## Operations + +* [Display](https://github.khronos.org/glTF-Sample-Viewer-Release/?model=https://raw.GithubUserContent.com/KhronosGroup/glTF-Sample-Assets/main/./Models/Box/glTF-Binary/Box.glb) in SampleViewer +* [Download GLB](https://raw.GithubUserContent.com/KhronosGroup/glTF-Sample-Assets/main/./Models/Box/glTF-Binary/Box.glb) +* [Model Directory](./) + +## Screenshot + +![screenshot](screenshot/screenshot.png) + +## Description + +Simple cube model. + +## Legal + +© 2017, Cesium. [CC BY 4.0 International](https://creativecommons.org/licenses/by/4.0/legalcode) + + - Cesium for Everything + +#### Assembled by modelmetadata \ No newline at end of file diff --git a/test/gltf-comparison/glTF_cases/Models/Box/glTF-Binary/Box.glb b/test/gltf-comparison/glTF_cases/Models/Box/glTF-Binary/Box.glb new file mode 100644 index 0000000000..95ec886b6b Binary files /dev/null and b/test/gltf-comparison/glTF_cases/Models/Box/glTF-Binary/Box.glb differ diff --git a/test/gltf-comparison/glTF_cases/Models/Box/glTF-Draco/Box.bin b/test/gltf-comparison/glTF_cases/Models/Box/glTF-Draco/Box.bin new file mode 100644 index 0000000000..bd2481d647 Binary files /dev/null and b/test/gltf-comparison/glTF_cases/Models/Box/glTF-Draco/Box.bin differ diff --git a/test/gltf-comparison/glTF_cases/Models/Box/glTF-Draco/Box.gltf b/test/gltf-comparison/glTF_cases/Models/Box/glTF-Draco/Box.gltf new file mode 100644 index 0000000000..d38bc8c830 --- /dev/null +++ b/test/gltf-comparison/glTF_cases/Models/Box/glTF-Draco/Box.gltf @@ -0,0 +1,152 @@ +{ + "asset": { + "generator": "COLLADA2GLTF", + "version": "2.0" + }, + "scene": 0, + "scenes": [ + { + "nodes": [ + 0 + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 0, + -1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1 + ] + }, + { + "mesh": 0 + } + ], + "meshes": [ + { + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 2 + }, + "indices": 0, + "mode": 4, + "material": 0, + "extensions": { + "KHR_draco_mesh_compression": { + "bufferView": 0, + "attributes": { + "NORMAL": 0, + "POSITION": 1 + } + } + } + } + ], + "name": "Mesh" + } + ], + "accessors": [ + { + "componentType": 5123, + "count": 36, + "max": [ + 23 + ], + "min": [ + 0 + ], + "type": "SCALAR" + }, + { + "componentType": 5126, + "count": 24, + "max": [ + 1.007843137254902, + 1.007843137254902, + 1.007843137254902 + ], + "min": [ + -1.007843137254902, + -1.007843137254902, + -1.007843137254902 + ], + "type": "VEC3" + }, + { + "componentType": 5126, + "count": 24, + "max": [ + 0.5004885197850513, + 0.5004885197850513, + 0.5004885197850513 + ], + "min": [ + -0.5004885197850513, + -0.5004885197850513, + -0.5004885197850513 + ], + "type": "VEC3" + } + ], + "materials": [ + { + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.800000011920929, + 0, + 0, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 1 + }, + "name": "Red", + "emissiveFactor": [ + 0, + 0, + 0 + ], + "alphaMode": "OPAQUE", + "doubleSided": false + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 118 + } + ], + "buffers": [ + { + "name": "Box", + "byteLength": 120, + "uri": "Box.bin" + } + ], + "extensionsRequired": [ + "KHR_draco_mesh_compression" + ], + "extensionsUsed": [ + "KHR_draco_mesh_compression" + ] +} diff --git a/test/gltf-comparison/glTF_cases/Models/Box/glTF-Embedded/Box.gltf b/test/gltf-comparison/glTF_cases/Models/Box/glTF-Embedded/Box.gltf new file mode 100644 index 0000000000..ea0e8ada69 --- /dev/null +++ b/test/gltf-comparison/glTF_cases/Models/Box/glTF-Embedded/Box.gltf @@ -0,0 +1,142 @@ +{ + "asset": { + "generator": "COLLADA2GLTF", + "version": "2.0" + }, + "scene": 0, + "scenes": [ + { + "nodes": [ + 0 + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + { + "mesh": 0 + } + ], + "meshes": [ + { + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 2 + }, + "indices": 0, + "mode": 4, + "material": 0 + } + ], + "name": "Mesh" + } + ], + "accessors": [ + { + "bufferView": 0, + "byteOffset": 0, + "componentType": 5123, + "count": 36, + "max": [ + 23 + ], + "min": [ + 0 + ], + "type": "SCALAR" + }, + { + "bufferView": 1, + "byteOffset": 0, + "componentType": 5126, + "count": 24, + "max": [ + 1.0, + 1.0, + 1.0 + ], + "min": [ + -1.0, + -1.0, + -1.0 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 288, + "componentType": 5126, + "count": 24, + "max": [ + 0.5, + 0.5, + 0.5 + ], + "min": [ + -0.5, + -0.5, + -0.5 + ], + "type": "VEC3" + } + ], + "materials": [ + { + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.800000011920929, + 0.0, + 0.0, + 1.0 + ], + "metallicFactor": 0.0 + }, + "name": "Red" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 576, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 576, + "byteStride": 12, + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 648, + "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAvwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAPwAAAL8AAAC/AAAAvwAAAL8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAC/AAAAPwAAAL8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAABAAIAAwACAAEABAAFAAYABwAGAAUACAAJAAoACwAKAAkADAANAA4ADwAOAA0AEAARABIAEwASABEAFAAVABYAFwAWABUA" + } + ] +} diff --git a/test/gltf-comparison/glTF_cases/Models/Box/glTF/Box.gltf b/test/gltf-comparison/glTF_cases/Models/Box/glTF/Box.gltf new file mode 100644 index 0000000000..7f603f07fc --- /dev/null +++ b/test/gltf-comparison/glTF_cases/Models/Box/glTF/Box.gltf @@ -0,0 +1,142 @@ +{ + "asset": { + "generator": "COLLADA2GLTF", + "version": "2.0" + }, + "scene": 0, + "scenes": [ + { + "nodes": [ + 0 + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + { + "mesh": 0 + } + ], + "meshes": [ + { + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 2 + }, + "indices": 0, + "mode": 4, + "material": 0 + } + ], + "name": "Mesh" + } + ], + "accessors": [ + { + "bufferView": 0, + "byteOffset": 0, + "componentType": 5123, + "count": 36, + "max": [ + 23 + ], + "min": [ + 0 + ], + "type": "SCALAR" + }, + { + "bufferView": 1, + "byteOffset": 0, + "componentType": 5126, + "count": 24, + "max": [ + 1.0, + 1.0, + 1.0 + ], + "min": [ + -1.0, + -1.0, + -1.0 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 288, + "componentType": 5126, + "count": 24, + "max": [ + 0.5, + 0.5, + 0.5 + ], + "min": [ + -0.5, + -0.5, + -0.5 + ], + "type": "VEC3" + } + ], + "materials": [ + { + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.800000011920929, + 0.0, + 0.0, + 1.0 + ], + "metallicFactor": 0.0 + }, + "name": "Red" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 576, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 576, + "byteStride": 12, + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 648, + "uri": "Box0.bin" + } + ] +} diff --git a/test/gltf-comparison/glTF_cases/Models/Box/glTF/Box0.bin b/test/gltf-comparison/glTF_cases/Models/Box/glTF/Box0.bin new file mode 100644 index 0000000000..d7798abb51 Binary files /dev/null and b/test/gltf-comparison/glTF_cases/Models/Box/glTF/Box0.bin differ diff --git a/test/gltf-comparison/glTF_cases/Models/Box/metadata.json b/test/gltf-comparison/glTF_cases/Models/Box/metadata.json new file mode 100644 index 0000000000..f399e0e04c --- /dev/null +++ b/test/gltf-comparison/glTF_cases/Models/Box/metadata.json @@ -0,0 +1,25 @@ +{ + "version": 2, + "legal": [ + { + "license": "CC-BY 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by/4.0/legalcode", + "artist": "Cesium", + "year": "2017", + "owner": "Cesium", + "what": "Everything", + "text": "CC BY 4.0 International", + "spdx": "CC-BY-4.0", + "icon": "https://licensebuttons.net/l/by/3.0/88x31.png" + } + ], + "tags": [ + "core", + "testing" + ], + "screenshot": "screenshot/screenshot.png", + "name": "Box", + "path": "./Models/Box", + "summary": "One mesh and one material. Start with this.", + "createReadme": true +} \ No newline at end of file diff --git a/test/gltf-comparison/glTF_cases/Models/Box/screenshot/screenshot-x150.png b/test/gltf-comparison/glTF_cases/Models/Box/screenshot/screenshot-x150.png new file mode 100644 index 0000000000..9b66ded7f5 Binary files /dev/null and b/test/gltf-comparison/glTF_cases/Models/Box/screenshot/screenshot-x150.png differ diff --git a/test/gltf-comparison/glTF_cases/Models/Box/screenshot/screenshot.png b/test/gltf-comparison/glTF_cases/Models/Box/screenshot/screenshot.png new file mode 100644 index 0000000000..b74e785857 Binary files /dev/null and b/test/gltf-comparison/glTF_cases/Models/Box/screenshot/screenshot.png differ diff --git a/test/gltf-comparison/glTF_cases/README.md b/test/gltf-comparison/glTF_cases/README.md new file mode 100644 index 0000000000..189b8cc61a --- /dev/null +++ b/test/gltf-comparison/glTF_cases/README.md @@ -0,0 +1,3 @@ +Files here are copied directly from https://github.com/KhronosGroup/glTF-Sample-Assets + +Each glTF model includes its own license and attribution in LICENSE.md and metadata.json. \ No newline at end of file diff --git a/test/gltf-comparison/include/GLTFViewer.h b/test/gltf-comparison/include/GLTFViewer.h new file mode 100644 index 0000000000..f49ae443e5 --- /dev/null +++ b/test/gltf-comparison/include/GLTFViewer.h @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef TNT_GLTFVIEWER_H +#define TNT_GLTFVIEWER_H + +#endif // TNT_GLTFVIEWER_H diff --git a/test/gltf-comparison/include/ImageExpectations.h b/test/gltf-comparison/include/ImageExpectations.h new file mode 100644 index 0000000000..6a647647d3 --- /dev/null +++ b/test/gltf-comparison/include/ImageExpectations.h @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef TNT_IMAGE_EXPECTATIONS_H +#define TNT_IMAGE_EXPECTATIONS_H + +#include +#include +#include + +#include "gtest/gtest.h" + +// Arguments are (Renderer* renderer, ImageExpectations& expectations, +// ScreenshotParams screenshotParams) +#define EXPECT_IMAGE(renderer, expectations, screenshotParams) \ +do { \ + expectations.addExpectation( \ + __FILE__, \ + __LINE__, \ + renderer, \ + screenshotParams); \ +} while (0) + +namespace test { + +/** + * Stores user-provided configuration values for an image expectation + */ +class ScreenshotParams { +public: + // TODO(b/422804941): Add a set of environments where this test should use a different golden. + ScreenshotParams(int width, int height, std::string fileName, + bool isSrgb = false); + + int width() const; + int height() const; + bool isSrgb() const; + + static std::string actualDirectoryPath(); + std::string actualFileName() const; + std::string actualFilePath() const; + static std::string expectedDirectoryPath(); + std::string expectedFileName() const; + std::string expectedFilePath() const; + const std::string filePrefix() const; + +private: + int mWidth; + int mHeight; + bool mIsSrgb; + std::string mFileName; +}; + +/** + * When created adds a command to the GPU pipeline to copy the render target into a buffer and + * stores the result. + * If this object is destroyed before the GPU pipeline is flushed it will leak memory in order to + * avoid the GPU pipeline callback being a use-after-free. + */ +class RenderTargetDump { +public: + using ReadPixels = std::function; + static ReadPixels readFromRenderer(filament::Renderer* renderer); + + RenderTargetDump(const ReadPixels& readPixels, const ScreenshotParams& params); + RenderTargetDump(RenderTargetDump&& other) = default; + RenderTargetDump& operator=(RenderTargetDump&& other) = default; + ~RenderTargetDump(); + + /** + * Should only bue used if BytesFilled returns true. + * @return The hash of the stored bytes. + */ + uint32_t hash() const; + /** + * Gets the bytes of the render target. The hash should usually be preferable for comparisons + * but this is available for debugging. + * @return The stored bytes. + */ + const std::vector& bytes() const; + /** + * Thread safe as this is backed by an atomic. + * Once this returns true it will never return false. + * @return Whether the bytes have actually been copied from the GPU to the buffer. + */ + bool bytesFilled() const; + +private: + struct Internal { + explicit Internal(const ScreenshotParams& params); + ScreenshotParams params; + std::atomic bytesFilled = false; + std::vector bytes; + + uint32_t hash() const; + }; + + // We need a memory location that won't be invalidated to pass to GPU callbacks as they can't + // be canceled during the destructor. + std::unique_ptr mInternal; +}; + +class LoadedPng { +public: + explicit LoadedPng(std::string filePath); + + uint32_t hash() const; + + const std::vector& bytes() const; + +private: + std::string mFilePath; + std::vector mBytes; +}; + +class ImageExpectation { +public: + ImageExpectation(const char* fileName, int lineNumber, RenderTargetDump::ReadPixels readPixels, + ScreenshotParams params); + + void evaluate(); + +private: + void compareImage() const; + + bool mEvaluated = false; + const char* mFileName; + int mLineNumber; + ScreenshotParams mParams; + RenderTargetDump mResult; +}; + +class ImageExpectations { +public: + explicit ImageExpectations() = default; + ~ImageExpectations(); + + /** + * Not meant to be called directly, use EXPECT_IMAGE to get the file name and line number + */ + void addExpectation(const char* fileName, int lineNumber, + filament::Renderer* renderer, ScreenshotParams params); + + void evaluate(); + + static void markImageAsFailure(const std::string& imagePrefix); + +private: + // Store expectations in unique pointers because they are self referential. + std::vector> mExpectations; +}; + +} // namespace test + +#endif //TNT_IMAGE_EXPECTATIONS_H diff --git a/test/gltf-comparison/src/GLTFViewer.cpp b/test/gltf-comparison/src/GLTFViewer.cpp new file mode 100644 index 0000000000..87f8c151b8 --- /dev/null +++ b/test/gltf-comparison/src/GLTFViewer.cpp @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "GLTFViewer.h" diff --git a/test/gltf-comparison/src/ImageExpectations.cpp b/test/gltf-comparison/src/ImageExpectations.cpp new file mode 100644 index 0000000000..f5ec2d82cb --- /dev/null +++ b/test/gltf-comparison/src/ImageExpectations.cpp @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ImageExpectations.h" + +#include "gmock/gmock.h" +#include "absl/strings/str_format.h" +#include "utils/Hash.h" +#include + +#include +#include +#include + +namespace test { + +ScreenshotParams::ScreenshotParams(int width, int height, std::string fileName, bool isSrgb) + : mWidth(width), + mHeight(height), + mIsSrgb(isSrgb), + mFileName(std::move(fileName)) {} + +int ScreenshotParams::width() const { + return mWidth; +} + +int ScreenshotParams::height() const { + return mHeight; +} + +bool ScreenshotParams::isSrgb() const { + return mIsSrgb; +} + +std::string ScreenshotParams::actualDirectoryPath() { + return "images/actual_images"; +} + +std::string ScreenshotParams::actualFileName() const { + return absl::StrFormat("%s_actual.png", mFileName); +} + +std::string ScreenshotParams::actualFilePath() const { + return absl::StrFormat("%s/%s", actualDirectoryPath(), actualFileName()); +} + +std::string ScreenshotParams::expectedDirectoryPath() { + return "images/expected_images"; +} + +std::string ScreenshotParams::expectedFileName() const { + return absl::StrFormat("%s.png", mFileName); +} + +std::string ScreenshotParams::expectedFilePath() const { + return absl::StrFormat("%s/%s", expectedDirectoryPath(), expectedFileName()); +} + +const std::string ScreenshotParams::filePrefix() const { + // TODO(b/422804941): If there are platform specific goldens, when on those platforms append a + // unique platform identifying string to this. + return mFileName; +} + +ImageExpectation::ImageExpectation(const char* fileName, int lineNumber, + RenderTargetDump::ReadPixels readPixels, ScreenshotParams params) + : mFileName(fileName), + mLineNumber(lineNumber), + mParams(std::move(params)), + mResult(readPixels, mParams) {} + +void ImageExpectation::evaluate() { + // Ensure this is only evaluated once. + if (mEvaluated) { + return; + } + mEvaluated = true; + + // Do the actual image comparison inside a scoped trace with the stored file and line. + { + testing::ScopedTrace trace(mFileName, mLineNumber, ""); + compareImage(); + } +} + +void ImageExpectation::compareImage() const { + bool bytesFilled = mResult.bytesFilled(); + // If this fails, it likely means that BackendTest::flushAndWait needs to be called before + // ImageExpectations is evaluated or destroyed. + EXPECT_THAT(bytesFilled, testing::IsTrue()) + << "Render target wasn't copied to the buffer for " << mFileName; + if (bytesFilled) { + // Rather than directly compare the two images compare their hashes because comparing very + // large arrays generates way too much debug output to be useful. + uint32_t actualHash = mResult.hash(); +#ifndef FILAMENT_IOS + LoadedPng loadedImage(mParams.expectedFilePath()); + uint32_t loadedImageHash = loadedImage.hash(); + auto compareToImageMatcher = testing::Eq(loadedImageHash); + if (!testing::Matches(compareToImageMatcher)(actualHash)) { + ImageExpectations::markImageAsFailure(mParams.filePrefix()); + } + EXPECT_THAT(actualHash, compareToImageMatcher) << mParams.expectedFileName(); +#endif + } +} + +ImageExpectations::~ImageExpectations() { + // Guarantee that all expectations are evaluated when this leaves scope even if the caller + // forgot to manually evaluate them. + evaluate(); +} + +void ImageExpectations::addExpectation(const char* fileName, int lineNumber, + filament::Renderer* renderer, ScreenshotParams params) { + mExpectations.emplace_back(std::make_unique( + fileName, lineNumber, + [renderer](uint32_t x, uint32_t y, uint32_t width, uint32_t height, + filament::backend::PixelBufferDescriptor&& pb) { + renderer->readPixels(x, y, width, height, std::move(pb)); + }, + std::move(params))); +} + +void ImageExpectations::evaluate() { + for (auto& expectation: mExpectations) { + expectation->evaluate(); + } + mExpectations.clear(); +} + +void ImageExpectations::markImageAsFailure(const std::string& imagePrefix) { + +} + +RenderTargetDump::RenderTargetDump(const RenderTargetDump::ReadPixels& readPixels, + const ScreenshotParams& params) + : mInternal(std::make_unique(params)) { + const size_t size = mInternal->params.width() * mInternal->params.height() * 4; + mInternal->bytes.resize(size); + + auto cb = [](void* buffer, size_t size, void* user) { + auto* internal = static_cast(user); + internal->bytesFilled = true; +#ifndef FILAMENT_IOS + image::LinearImage image; + if (internal->params.isSrgb()) { + image = image::toLinearWithAlpha(internal->params.width(), + internal->params.height(), + internal->params.width() * 4, (uint8_t*)buffer); + } else { + // The image data is already linear, so pass in transforms that simply go from uint8_t + // to float. toLinearWithAlpha divides the float values by uint8_t max so there's no + // need to scale it to [0, 1] + image = image::toLinearWithAlpha( + internal->params.width(), internal->params.height(), + internal->params.width() * 4, (uint8_t*) buffer, + [](uint8_t value) -> float { return value; }, + [](filament::math::float4 rgba) -> filament::math::float4 { return rgba; }); + } + std::string filePath = internal->params.actualFilePath(); + std::ofstream pngStream(filePath, std::ios::binary | std::ios::trunc); + // To avoid going from linear -> sRGB -> linear save the PNG as linear. + image::ImageEncoder::encode(pngStream, image::ImageEncoder::Format::PNG_LINEAR, image, "", + filePath); +#endif + }; + filament::backend::PixelBufferDescriptor pb(mInternal->bytes.data(), size, + filament::backend::PixelDataFormat::RGBA, filament::backend::PixelDataType::UBYTE, cb, + (void*)mInternal.get()); + readPixels(0, 0, mInternal->params.width(), mInternal->params.height(), std::move(pb)); +} + +RenderTargetDump::~RenderTargetDump() { + // If the GPU callback hasn't been made yet then there's a callback elsewhere that has a copy of + // the internal pointer. But there's no guarantee that the callback will be ever made if the GPU + // pipeline wasn't run for some reason. So it is necessary to leak the memory. + // It would be possible to try to coordinate with the callback to have it clean up the memory, + // but if this condition happens there's already an issue with the test case so there's no need. + if (!bytesFilled()) { + mInternal.release(); + } +} + +uint32_t RenderTargetDump::Internal::hash() const { + return utils::hash::murmur3((uint32_t*)bytes.data(), bytes.size() / 4, 0); +} + +uint32_t RenderTargetDump::hash() const { + return mInternal->hash(); +} + +const std::vector& RenderTargetDump::bytes() const { + return mInternal->bytes; +} + +bool RenderTargetDump::bytesFilled() const { + return mInternal->bytesFilled; +} + +RenderTargetDump::Internal::Internal(const ScreenshotParams& params) : params(params) {} + +LoadedPng::LoadedPng(std::string filePath) : mFilePath(std::move(filePath)) { +#ifndef FILAMENT_IOS + std::ifstream pngStream(mFilePath, std::ios::binary); + image::LinearImage loadedImage = image::ImageDecoder::decode(pngStream, filePath, + image::ImageDecoder::ColorSpace::LINEAR); + size_t valuesInImage = loadedImage.getWidth() * loadedImage.getHeight() * + loadedImage.getChannels(); + // The linear image is loaded with each component as [0.0, 1.0] but should be [0, 255], so + // convert them. + mBytes = std::vector(valuesInImage); + for (int i = 0; i < valuesInImage; ++i) { + mBytes[i] = static_cast(loadedImage.get()[i] * 255.0f); + } +#endif + // For platforms that don't support the image loading library, leave the loaded data blank. +} + +uint32_t LoadedPng::hash() const { + EXPECT_THAT(mBytes, testing::Not(testing::IsEmpty())) + << "Failed to load expected test result: " << mFilePath << ".\n" + << "Did you forget to sync CMake after updating the expected image in the source " + "directory?"; + if (mBytes.empty()) { + return 0; + } + return utils::hash::murmur3((uint32_t*)mBytes.data(), mBytes.size() / 4, 0); +} + +const std::vector& LoadedPng::bytes() const { + return mBytes; +} + +} // namespace test diff --git a/test/gltf-comparison/src/main.cpp b/test/gltf-comparison/src/main.cpp new file mode 100644 index 0000000000..412e38a151 --- /dev/null +++ b/test/gltf-comparison/src/main.cpp @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed 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 CONDITIONS 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) { + testing::InitGoogleTest(&argc, argv); + + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/test/gltf-comparison/src/test_CompareGLTF.cpp b/test/gltf-comparison/src/test_CompareGLTF.cpp new file mode 100644 index 0000000000..558e93a645 --- /dev/null +++ b/test/gltf-comparison/src/test_CompareGLTF.cpp @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed 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 CONDITIONS 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 "ImageExpectations.h" + +using namespace filament; +using utils::Entity; +using utils::EntityManager; + +struct App { + Config config; + VertexBuffer* vb; + IndexBuffer* ib; + Material* mat; + Camera* cam; + Entity camera; + Skybox* skybox; + Entity renderable; +}; + +struct Vertex { + filament::math::float2 position; + uint32_t color; +}; + +static const Vertex TRIANGLE_VERTICES[3] = { + {{1, 0}, 0xffff0000u}, + {{cos(M_PI * 2 / 3), sin(M_PI * 2 / 3)}, 0xff00ff00u}, + {{cos(M_PI * 4 / 3), sin(M_PI * 4 / 3)}, 0xff0000ffu}, +}; + +static constexpr uint16_t TRIANGLE_INDICES[3] = { 0, 1, 2 }; + +const std::string material = R"(material { + name : BakedColor, + requires : [ + color + ], + shadingModel : unlit, + culling : none, + featureLevel : 0 +} + +fragment { + void material(inout MaterialInputs material) { + prepareMaterial(material); + material.baseColor = getColor(); + } +} +)"; + +class CompareGLTFTest : public testing::Test { +public: + test::ImageExpectations mExpectations; + + void postRender(Engine*, View* view, Scene*, Renderer* renderer); +}; + +void CompareGLTFTest::postRender(Engine*, View* view, Scene*, Renderer* renderer) { + EXPECT_IMAGE(renderer, mExpectations, test::ScreenshotParams(512, 512, "GLTF")); + + FilamentApp::get().close(); +} + +TEST_F(CompareGLTFTest, Compare) { + App app; + + auto setup = [&app](Engine* engine, View* view, Scene* scene) { + app.skybox = Skybox::Builder().color({0.1, 0.125, 0.25, 1.0}).build(*engine); + scene->setSkybox(app.skybox); + view->setPostProcessingEnabled(false); + static_assert(sizeof(Vertex) == 12, "Strange vertex size."); + /*app.vb = VertexBuffer::Builder() + .vertexCount(3) + .bufferCount(1) + .attribute(VertexAttribute::POSITION, 0, VertexBuffer::AttributeType::FLOAT2, 0, 12) + .attribute(VertexAttribute::COLOR, 0, VertexBuffer::AttributeType::UBYTE4, 8, 12) + .normalized(VertexAttribute::COLOR) + .build(*engine); + app.vb->setBufferAt(*engine, 0, + VertexBuffer::BufferDescriptor(TRIANGLE_VERTICES, 36, nullptr)); + app.ib = IndexBuffer::Builder() + .indexCount(3) + .bufferType(IndexBuffer::IndexType::USHORT) + .build(*engine); + app.ib->setBuffer(*engine, + IndexBuffer::BufferDescriptor(TRIANGLE_INDICES, 6, nullptr)); + app.mat = Material::Builder() + .package(material.c_str(), material.size()) + .build(*engine); + app.renderable = EntityManager::get().create(); + RenderableManager::Builder(1) + .boundingBox({{ -1, -1, -1 }, { 1, 1, 1 }}) + .material(0, app.mat->getDefaultInstance()) + .geometry(0, RenderableManager::PrimitiveType::TRIANGLES, app.vb, app.ib, 0, 3) + .culling(false) + .receiveShadows(false) + .castShadows(false) + .build(*engine, app.renderable); + scene->addEntity(app.renderable);*/ + app.camera = utils::EntityManager::get().create(); + app.cam = engine->createCamera(app.camera); + view->setCamera(app.cam); + }; + + auto cleanup = [&app](Engine* engine, View*, Scene*) { + engine->destroy(app.skybox); + //engine->destroy(app.renderable); + //engine->destroy(app.mat); + //engine->destroy(app.vb); + //engine->destroy(app.ib); + engine->destroyCameraComponent(app.camera); + utils::EntityManager::get().destroy(app.camera); + }; + + FilamentApp::get().animate([&app](Engine* engine, View* view, double now) { + constexpr float ZOOM = 1.5f; + const uint32_t w = view->getViewport().width; + const uint32_t h = view->getViewport().height; + const float aspect = (float) w / h; + app.cam->setProjection(Camera::Projection::ORTHO, + -aspect * ZOOM, aspect * ZOOM, + -ZOOM, ZOOM, 0, 1); + auto& tcm = engine->getTransformManager(); + tcm.setTransform(tcm.getInstance(app.renderable), + filament::math::mat4f::rotation(now, filament::math::float3{ 0, 0, 1 })); + }); + + app.config.backend = filament::Engine::Backend::METAL; + + FilamentApp::get().run(app.config, setup, cleanup, FilamentApp::ImGuiCallback(), + FilamentApp::PreRenderCallback(), + [this](Engine* engine, View* view, Scene* scene, Renderer* renderer) { + postRender(engine, view, scene, renderer); + }); +} \ No newline at end of file