diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index 6cb030927..0bb85f3ba 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -1,10 +1,22 @@ name: CI Build and Test +# Changes that only touch the documentation skip this workflow: the docs folder, +# mkdocs.yml and the Markdown files at the repository root. Markdown files elsewhere +# (for example template resources under Sources) still run it. The MkDocs build has +# its own workflow. on: push: branches: [master, develop] + paths-ignore: + - 'docs/**' + - 'mkdocs.yml' + - '*.md' pull_request: branches: [master, develop] + paths-ignore: + - 'docs/**' + - 'mkdocs.yml' + - '*.md' jobs: python-script-tests: diff --git a/.github/workflows/swiftformat-lint.yml b/.github/workflows/swiftformat-lint.yml index bbcd78cd4..7744d9014 100644 --- a/.github/workflows/swiftformat-lint.yml +++ b/.github/workflows/swiftformat-lint.yml @@ -3,6 +3,11 @@ name: Formatting Validation on: pull_request: branches: [master, develop] + # Documentation-only changes have no Swift to lint. + paths-ignore: + - 'docs/**' + - 'mkdocs.yml' + - '*.md' jobs: lint: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7195f5458..4dbf6e5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ # Changelog +## v0.22.0 - 2026-10-04 +### šŸž Fixes +- [Patch] Remember failed texture writes per export, not per Blender session (#1263) (fa706bf…) +### šŸ“š Docs +- [Docs] Updated engine architecture docs (#1286) (3ff79c0…) +- [Docs] Actors and locks: benchmark, lock census of the frame and rules (#1294) (0e652b3…) +### šŸš€ Features +- [Feature] Keep an export's assets in a folder of their own (#1268) (3c006f5…) +- [Feature] Animation foundation and character deformation stack: pose layers, physics pose, compute skinning, DQS/DDM, morph targets, pose drivers, XPBD muscles, ML deformer, motion-capture seams (#1259) (ae6df3e…) +- [Feature] Reach IK: a target per chain, in world, model, shoulder or ground space (#1283) (7f63272…) ## v0.21.0 - 2026-09-26 ### šŸž Fixes - [Bugfix] Restore StarterDemo keyboard input (014a559…) diff --git a/Examples/CodeComponents/SamplePluginPackage/Package.swift b/Examples/CodeComponents/SamplePluginPackage/Package.swift index 1bacc85bd..894a89769 100644 --- a/Examples/CodeComponents/SamplePluginPackage/Package.swift +++ b/Examples/CodeComponents/SamplePluginPackage/Package.swift @@ -1,3 +1,13 @@ +// +// Package.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // swift-tools-version: 6.0 import PackageDescription diff --git a/Examples/CodeComponents/SamplePluginPackage/Sources/SampleEditorPluginPackage/SampleMenus.swift b/Examples/CodeComponents/SamplePluginPackage/Sources/SampleEditorPluginPackage/SampleMenus.swift index 40adfb01b..5814f9f5b 100644 --- a/Examples/CodeComponents/SamplePluginPackage/Sources/SampleEditorPluginPackage/SampleMenus.swift +++ b/Examples/CodeComponents/SamplePluginPackage/Sources/SampleEditorPluginPackage/SampleMenus.swift @@ -1,3 +1,13 @@ +// +// SampleMenus.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import SamplePluginPackage import UntoldComponentKit diff --git a/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/GeneratedGeometry.swift b/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/GeneratedGeometry.swift index 7fb5e88e2..10929d303 100644 --- a/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/GeneratedGeometry.swift +++ b/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/GeneratedGeometry.swift @@ -1,3 +1,13 @@ +// +// GeneratedGeometry.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation import ModelIO import simd diff --git a/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/PulseClock.swift b/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/PulseClock.swift index b47fbd046..af56fc144 100644 --- a/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/PulseClock.swift +++ b/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/PulseClock.swift @@ -1,3 +1,13 @@ +// +// PulseClock.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation /// A clock shared by everything that pulses, so it all moves in step. It stands in for a diff --git a/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/SplinePath.swift b/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/SplinePath.swift index d1f0e4255..b261877c1 100644 --- a/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/SplinePath.swift +++ b/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/SplinePath.swift @@ -1,3 +1,13 @@ +// +// SplinePath.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation import MetalKit import ModelIO diff --git a/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/Torus.swift b/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/Torus.swift index dc8fc0f0b..f426cd044 100644 --- a/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/Torus.swift +++ b/Examples/CodeComponents/SamplePluginPackage/Sources/SamplePluginPackage/Torus.swift @@ -1,3 +1,13 @@ +// +// Torus.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation import MetalKit import ModelIO diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProject/AppDelegate.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProject/AppDelegate.swift index 29a9bb0d4..c1793f475 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProject/AppDelegate.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProject/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // AppDelegate.swift // SampleProject diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameScene.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameScene.swift index ceefaa9f4..00e33233a 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameScene.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameScene.swift // SampleProject diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameSceneUtils.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameSceneUtils.swift index 5d52c0c0a..2ffaf5018 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameSceneUtils.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameSceneUtils.swift @@ -1,3 +1,13 @@ +// +// GameSceneUtils.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameSceneUtils.swift // SampleProject diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameViewController.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameViewController.swift index 2a21fbedc..ba648f491 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameViewController.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProject/GameViewController.swift @@ -1,3 +1,13 @@ +// +// GameViewController.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameViewController.swift // SampleProject diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/Bobber.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/Bobber.swift index 05ce9f75a..8cfac6bc6 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/Bobber.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/Bobber.swift @@ -1,3 +1,13 @@ +// +// Bobber.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import SamplePluginPackage import simd import UntoldComponentKit diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/GameRules.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/GameRules.swift index 5b47615b9..7146fea39 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/GameRules.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/GameRules.swift @@ -1,3 +1,13 @@ +// +// GameRules.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import UntoldComponentKit import UntoldEngine diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/SampleTools.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/SampleTools.swift index 28526aca9..6104874cc 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/SampleTools.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/SampleTools.swift @@ -1,3 +1,13 @@ +// +// SampleTools.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // Editor-only. The editor compiles this folder with UNTOLD_EDITOR defined; the game does not, // so nothing below ends up in the game binary. #if UNTOLD_EDITOR diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/SpawnPoint.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/SpawnPoint.swift index f0640ee3a..ba982aacc 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/SpawnPoint.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/SpawnPoint.swift @@ -1,3 +1,13 @@ +// +// SpawnPoint.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation import simd import UntoldComponentKit diff --git a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/Spinner.swift b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/Spinner.swift index ff7ad4b14..120e1f50c 100644 --- a/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/Spinner.swift +++ b/Examples/CodeComponents/SampleProject/Sources/SampleProjectPlugins/Spinner.swift @@ -1,3 +1,13 @@ +// +// Spinner.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import simd import UntoldComponentKit import UntoldEngine diff --git a/Examples/RenderingExtensions/ApplicationLocal/Shaders/TintSurface.metal b/Examples/RenderingExtensions/ApplicationLocal/Shaders/TintSurface.metal index c964f4c5b..7b86f3a9c 100644 --- a/Examples/RenderingExtensions/ApplicationLocal/Shaders/TintSurface.metal +++ b/Examples/RenderingExtensions/ApplicationLocal/Shaders/TintSurface.metal @@ -1,3 +1,13 @@ +// +// TintSurface.metal +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + #include #include diff --git a/Examples/RenderingExtensions/ApplicationLocal/TintSurfaceRenderExtension.swift b/Examples/RenderingExtensions/ApplicationLocal/TintSurfaceRenderExtension.swift index 052bc16ec..4fc929c89 100644 --- a/Examples/RenderingExtensions/ApplicationLocal/TintSurfaceRenderExtension.swift +++ b/Examples/RenderingExtensions/ApplicationLocal/TintSurfaceRenderExtension.swift @@ -1,3 +1,13 @@ +// +// TintSurfaceRenderExtension.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation import simd import UntoldEngine diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Package.swift b/Examples/RenderingExtensions/SwiftPackagePlugin/Package.swift index 5b32f5228..36e268317 100644 --- a/Examples/RenderingExtensions/SwiftPackagePlugin/Package.swift +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Package.swift @@ -1,3 +1,13 @@ +// +// Package.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // swift-tools-version: 6.0 import PackageDescription diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Shaders/WaterRenderPlugin.metal b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Shaders/WaterRenderPlugin.metal index af7bc942e..5e43c4ef8 100644 --- a/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Shaders/WaterRenderPlugin.metal +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Shaders/WaterRenderPlugin.metal @@ -1,3 +1,13 @@ +// +// WaterRenderPlugin.metal +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + #include #include diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/WaterRenderPlugin.swift b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/WaterRenderPlugin.swift index b8b832a14..25cd8332a 100644 --- a/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/WaterRenderPlugin.swift +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/WaterRenderPlugin.swift @@ -1,3 +1,13 @@ +// +// WaterRenderPlugin.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation import Metal import simd diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Tests/WaterRenderPluginTests/WaterRenderPluginTests.swift b/Examples/RenderingExtensions/SwiftPackagePlugin/Tests/WaterRenderPluginTests/WaterRenderPluginTests.swift index 758272393..2fbc3bee0 100644 --- a/Examples/RenderingExtensions/SwiftPackagePlugin/Tests/WaterRenderPluginTests/WaterRenderPluginTests.swift +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Tests/WaterRenderPluginTests/WaterRenderPluginTests.swift @@ -1,3 +1,13 @@ +// +// WaterRenderPluginTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Metal import UntoldEngine import WaterRenderPlugin diff --git a/Package.swift b/Package.swift index f84f8b19c..f7a5f034e 100644 --- a/Package.swift +++ b/Package.swift @@ -64,6 +64,10 @@ let package = Package( // @UntoldMenu, reflection, scene storage and the per-frame system. Platform neutral. .library(name: "UntoldComponentKit", targets: ["UntoldComponentKit"]), + // Cook-time mesh work: the automatic LOD chains of cooked models. For tools + // that cook assets (the CLI, an editor), not for the apps that load them. + .library(name: "UntoldEngineMeshCook", targets: ["UntoldEngineMeshCook"]), + // NOTE: Demo executables are intentionally NOT declared as products so they // stay hidden from consumers of the package. SwiftPM creates implicit // products for executable targets in the root package, so they remain @@ -88,6 +92,14 @@ let package = Package( .headerSearchPath("include"), ] ), + // meshoptimizer (MIT), unmodified: the mesh simplifier behind the cook's automatic + // LOD chains. UntoldEngineMeshCook calls it from Swift through its C API. + .target( + name: "CMeshOptimizer", + path: "Sources/CMeshOptimizer", + exclude: ["LICENSE.md", "README.md"], + publicHeadersPath: "include" + ), .target( name: "UntoldEngine", dependencies: ["CShaderTypes"], @@ -121,6 +133,17 @@ let package = Package( ] ), + // The cook's mesh work. A module of its own so that UntoldEngine does not import + // CMeshOptimizer: whatever compiles against a built UntoldEngine module without + // SwiftPM (the editor compiles a project's components that way) would otherwise + // need the module map of that C++ library as well. + .target( + name: "UntoldEngineMeshCook", + dependencies: ["UntoldEngine", "CMeshOptimizer"], + path: "Sources/UntoldEngineMeshCook", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + // Vision OS target .target( name: "UntoldEngineXR", @@ -273,14 +296,14 @@ let package = Package( ), .testTarget( name: "UntoldEngineTests", - dependencies: ["UntoldEngine", "UntoldEngineShaderSupport"], + dependencies: ["UntoldEngine", "UntoldEngineMeshCook", "UntoldEngineShaderSupport"], path: "Tests/UntoldEngineTests", swiftSettings: [.swiftLanguageMode(.v6)] ), // Render-specific test target .testTarget( name: "UntoldEngineRenderTests", - dependencies: ["UntoldEngine"], + dependencies: ["UntoldEngine", "UntoldEngineMeshCook"], path: "Tests/UntoldEngineRenderTests", exclude: ["Resources/compare_psnr.py"], resources: [ diff --git a/README.md b/README.md index 983824a51..1913a639c 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Clone the repository and launch the Starter Demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.21.0 +git checkout v0.22.0 swift run StarterDemo ``` diff --git a/Sources/CMeshOptimizer/LICENSE.md b/Sources/CMeshOptimizer/LICENSE.md new file mode 100644 index 000000000..468827f4b --- /dev/null +++ b/Sources/CMeshOptimizer/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016-2026 Arseny Kapoulkine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Sources/CMeshOptimizer/README.md b/Sources/CMeshOptimizer/README.md new file mode 100644 index 000000000..1878d3791 --- /dev/null +++ b/Sources/CMeshOptimizer/README.md @@ -0,0 +1,14 @@ +# CMeshOptimizer + +Part of [meshoptimizer](https://github.com/zeux/meshoptimizer) 1.3 (tag `v1.3`), by Arseny Kapoulkine, under the MIT License (see `LICENSE.md`). The files are unmodified copies: + +- `include/meshoptimizer.h` is `src/meshoptimizer.h`. +- The `.cpp` files are those of `src/` that the engine uses: the simplifier, the index generator, the vertex cache and vertex fetch optimizers, and the allocator they share. The header declares the whole library; a function of a file that is not here does not link. + +The `UntoldEngineMeshCook` module calls the C API from Swift (`import CMeshOptimizer`); no C++ interoperability is needed. It is used by the asset cook, not by the renderer: `UntoldMeshLODCooker` builds the automatic LOD chain of a cooked model with the simplifier. + +Only `UntoldEngineMeshCook` imports this module. `UntoldEngine` must not: code that is compiled against a built `UntoldEngine` module without SwiftPM (the editor compiles a project's components that way) would then need this library's module map too, and fail with "missing required module 'CMeshOptimizer'". + +## Updating + +Replace the files with those of the new tag and update the version above. Add a file of `src/` when the engine starts to use it. Do not edit the files here: local changes would be lost on the next update. diff --git a/Sources/CMeshOptimizer/allocator.cpp b/Sources/CMeshOptimizer/allocator.cpp new file mode 100644 index 000000000..058fbf312 --- /dev/null +++ b/Sources/CMeshOptimizer/allocator.cpp @@ -0,0 +1,21 @@ +// This file is part of meshoptimizer library; see meshoptimizer.h for version/license details +#include "meshoptimizer.h" + +#include + +#ifdef MESHOPTIMIZER_ALLOC_EXPORT +meshopt_Allocator::Storage& meshopt_Allocator::storage() +{ + static Storage s = {::operator new, ::operator delete }; + return s; +} +#endif + +void meshopt_setAllocator(void* (MESHOPTIMIZER_ALLOC_CALLCONV* allocate)(size_t), void (MESHOPTIMIZER_ALLOC_CALLCONV* deallocate)(void*)) +{ + assert(allocate && deallocate); + + meshopt_Allocator::Storage& s = meshopt_Allocator::storage(); + s.allocate = allocate; + s.deallocate = deallocate; +} diff --git a/Sources/CMeshOptimizer/include/meshoptimizer.h b/Sources/CMeshOptimizer/include/meshoptimizer.h new file mode 100644 index 000000000..ff69785b5 --- /dev/null +++ b/Sources/CMeshOptimizer/include/meshoptimizer.h @@ -0,0 +1,1724 @@ +/** + * meshoptimizer - version 1.3 + * + * Copyright (C) 2016-2026, by Arseny Kapoulkine (arseny.kapoulkine@gmail.com) + * Report bugs and download new versions at https://github.com/zeux/meshoptimizer + * + * This library is distributed under the MIT License. See notice at the end of this file. + */ +#pragma once + +#include +#include + +/* Version macro; major * 1000 + minor * 10 + patch */ +#define MESHOPTIMIZER_VERSION 1030 /* 1.3 */ + +/* If no API is defined, assume default */ +#ifndef MESHOPTIMIZER_API +#define MESHOPTIMIZER_API +#endif + +/* Set the calling-convention for alloc/dealloc function pointers */ +#ifndef MESHOPTIMIZER_ALLOC_CALLCONV +#ifdef _MSC_VER +#define MESHOPTIMIZER_ALLOC_CALLCONV __cdecl +#else +#define MESHOPTIMIZER_ALLOC_CALLCONV +#endif +#endif + +/* Experimental APIs have unstable interface and might have implementation that's not fully tested or optimized */ +#ifndef MESHOPTIMIZER_EXPERIMENTAL +#define MESHOPTIMIZER_EXPERIMENTAL MESHOPTIMIZER_API +#endif + +/* C interface */ +#ifdef __cplusplus +extern "C" +{ +#endif + +/** + * Vertex attribute stream + * Each element takes size bytes, beginning at data, with stride controlling the spacing between successive elements (stride >= size). + */ +struct meshopt_Stream +{ + const void* data; + size_t size; + size_t stride; +}; + +/** + * Generates a vertex remap table from the vertex buffer and an optional index buffer and returns number of unique vertices + * As a result, all vertices that are binary equivalent map to the same (new) location, with no gaps in the resulting sequence. + * Resulting remap table maps old vertices to new vertices and can be used in meshopt_remapVertexBuffer/meshopt_remapIndexBuffer. + * Note that binary equivalence considers all vertex_size bytes, including padding which should be zero-initialized. + * + * destination must contain enough space for the resulting remap table (vertex_count elements) + * indices can be NULL if the input is unindexed + */ +MESHOPTIMIZER_API size_t meshopt_generateVertexRemap(unsigned int* destination, const unsigned int* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size); + +/** + * Generates a vertex remap table from multiple vertex streams and an optional index buffer and returns number of unique vertices + * As a result, all vertices that are binary equivalent map to the same (new) location, with no gaps in the resulting sequence. + * Resulting remap table maps old vertices to new vertices and can be used in meshopt_remapVertexBuffer/meshopt_remapIndexBuffer. + * To remap vertex buffers, you will need to call meshopt_remapVertexBuffer for each vertex stream. + * Note that binary equivalence considers all size bytes in each stream, including padding which should be zero-initialized. + * + * destination must contain enough space for the resulting remap table (vertex_count elements) + * indices can be NULL if the input is unindexed + * stream_count must be <= 16 + */ +MESHOPTIMIZER_API size_t meshopt_generateVertexRemapMulti(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, const struct meshopt_Stream* streams, size_t stream_count); + +/** + * Generates a vertex remap table from the vertex buffer and an optional index buffer and returns number of unique vertices + * As a result, all vertices that are equivalent map to the same (new) location, with no gaps in the resulting sequence. + * Equivalence is checked in two steps: vertex positions are compared for equality, and then the user-specified equality function is called (if provided). + * Resulting remap table maps old vertices to new vertices and can be used in meshopt_remapVertexBuffer/meshopt_remapIndexBuffer. + * + * destination must contain enough space for the resulting remap table (vertex_count elements) + * indices can be NULL if the input is unindexed + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * callback can be NULL if no additional equality check is needed; otherwise, it should return 1 if vertices with specified indices are equivalent and 0 if they are not + */ +MESHOPTIMIZER_API size_t meshopt_generateVertexRemapCustom(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, int (*callback)(void*, unsigned int, unsigned int), void* context); + +/** + * Generates vertex buffer from the source vertex buffer and remap table generated by meshopt_generateVertexRemap + * + * destination must contain enough space for the resulting vertex buffer (unique_vertex_count elements, returned by meshopt_generateVertexRemap) + * vertex_count should be the initial vertex count and not the value returned by meshopt_generateVertexRemap + */ +MESHOPTIMIZER_API void meshopt_remapVertexBuffer(void* destination, const void* vertices, size_t vertex_count, size_t vertex_size, const unsigned int* remap); + +/** + * Generate index buffer from the source index buffer and remap table generated by meshopt_generateVertexRemap + * + * destination must contain enough space for the resulting index buffer (index_count elements) + * indices can be NULL if the input is unindexed + */ +MESHOPTIMIZER_API void meshopt_remapIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const unsigned int* remap); + +/** + * Filter out redundant triangles from the index buffer and return the number of remaining indices + * Triangles are considered redundant if they are degenerate (two vertices have the same vertex key) or duplicate (matching triangle was present earlier). + * First vertex_size bytes of every vertex are compared for equality; typically vertex_size should be set to the size of the position attribute. + * Note that duplicate triangles with opposite windings are preserved, as they may be needed for double-sided rendering. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + */ +MESHOPTIMIZER_API size_t meshopt_filterIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size, size_t vertex_stride); + +/** + * Filter out redundant triangles from the index buffer and return the number of remaining indices + * Triangles are considered redundant if they are degenerate (two vertices have the same vertex key) or duplicate (matching triangle was present earlier). + * All bytes in specified streams are compared for equality; streams should include attributes relevant for position transform (e.g. bone influences). + * Note that duplicate triangles with opposite windings are preserved, as they may be needed for double-sided rendering. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + * stream_count must be <= 16 + */ +MESHOPTIMIZER_API size_t meshopt_filterIndexBufferMulti(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, const struct meshopt_Stream* streams, size_t stream_count); + +/** + * Generate index buffer that can be used for more efficient rendering when only a subset of the vertex attributes is necessary + * All vertices that are binary equivalent (wrt first vertex_size bytes) map to the first vertex in the original vertex buffer. + * This makes it possible to use the index buffer for Z pre-pass or shadowmap rendering, while using the original index buffer for regular rendering. + * Note that binary equivalence considers all vertex_size bytes, including padding which should be zero-initialized. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + */ +MESHOPTIMIZER_API void meshopt_generateShadowIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size, size_t vertex_stride); + +/** + * Generate index buffer that can be used for more efficient rendering when only a subset of the vertex attributes is necessary + * All vertices that are binary equivalent (wrt specified streams) map to the first vertex in the original vertex buffer. + * This makes it possible to use the index buffer for Z pre-pass or shadowmap rendering, while using the original index buffer for regular rendering. + * Note that binary equivalence considers all size bytes in each stream, including padding which should be zero-initialized. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + * stream_count must be <= 16 + */ +MESHOPTIMIZER_API void meshopt_generateShadowIndexBufferMulti(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, const struct meshopt_Stream* streams, size_t stream_count); + +/** + * Generates a remap table that maps all vertices with the same position to the same (existing) index. + * Similarly to meshopt_generateShadowIndexBuffer, this can be helpful to pre-process meshes for position-only rendering. + * This can also be used to implement algorithms that require positional-only connectivity, such as hierarchical simplification. + * + * destination must contain enough space for the resulting remap table (vertex_count elements) + * vertex_positions should have float3 position in the first 12 bytes of each vertex + */ +MESHOPTIMIZER_API void meshopt_generatePositionRemap(unsigned int* destination, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +/** + * Generate index buffer that can be used as a geometry shader input with triangle adjacency topology + * Each triangle is converted into a 6-vertex patch with the following layout: + * - 0, 2, 4: original triangle vertices + * - 1, 3, 5: vertices adjacent to edges 02, 24 and 40 + * The resulting patch can be rendered with geometry shaders using e.g. VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST_WITH_ADJACENCY. + * This can be used to implement algorithms like silhouette detection/expansion and other forms of GS-driven rendering. + * + * destination must contain enough space for the resulting index buffer (index_count*2 elements) + * vertex_positions should have float3 position in the first 12 bytes of each vertex + */ +MESHOPTIMIZER_API void meshopt_generateAdjacencyIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +/** + * Generate index buffer that can be used for PN-AEN tessellation with crack-free displacement + * Each triangle is converted into a 12-vertex patch with the following layout: + * - 0, 1, 2: original triangle vertices + * - 3, 4: opposing edge for edge 0, 1 + * - 5, 6: opposing edge for edge 1, 2 + * - 7, 8: opposing edge for edge 2, 0 + * - 9, 10, 11: dominant vertices for corners 0, 1, 2 + * The resulting patch can be rendered with hardware tessellation using PN-AEN and displacement mapping. + * See "Tessellation on Any Budget" (John McDonald, GDC 2011) for implementation details. + * + * destination must contain enough space for the resulting index buffer (index_count*4 elements) + * vertex_positions should have float3 position in the first 12 bytes of each vertex + */ +MESHOPTIMIZER_API void meshopt_generateTessellationIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +/** + * Generate index buffer that can be used for visibility buffer rendering and returns the size of the reorder table + * Each triangle's provoking vertex index is equal to primitive id; this allows passing it to the fragment shader using flat/nointerpolation attribute. + * This is important for performance on hardware where primitive id can't be accessed efficiently in fragment shader. + * The reorder table stores the original vertex id for each vertex in the new index buffer, and should be used in the vertex shader to load vertex data. + * The provoking vertex is assumed to be the first vertex in the triangle; if this is not the case (OpenGL), rotate each triangle (abc -> bca) before rendering. + * For maximum efficiency the input index buffer should be optimized for vertex cache first. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + * reorder must contain enough space for the worst case reorder table (vertex_count + index_count/3 elements) + */ +MESHOPTIMIZER_API size_t meshopt_generateProvokingIndexBuffer(unsigned int* destination, unsigned int* reorder, const unsigned int* indices, size_t index_count, size_t vertex_count); + +/** + * Vertex transform cache optimizer + * Reorders indices to reduce the number of GPU vertex shader invocations + * If index buffer contains multiple ranges for multiple draw calls, this function needs to be called on each range individually. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + */ +MESHOPTIMIZER_API void meshopt_optimizeVertexCache(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count); + +/** + * Vertex transform cache optimizer for strip-like caches + * Produces inferior results to meshopt_optimizeVertexCache from the GPU vertex cache perspective + * However, the resulting index order is more optimal if the goal is to reduce the triangle strip length or improve compression efficiency + * + * destination must contain enough space for the resulting index buffer (index_count elements) + */ +MESHOPTIMIZER_API void meshopt_optimizeVertexCacheStrip(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count); + +/** + * Vertex transform cache optimizer for FIFO caches + * Reorders indices to reduce the number of GPU vertex shader invocations + * Generally takes ~3x less time to optimize meshes but produces inferior results compared to meshopt_optimizeVertexCache + * If index buffer contains multiple ranges for multiple draw calls, this function needs to be called on each range individually. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + * cache_size should be less than the actual GPU cache size to avoid cache thrashing + */ +MESHOPTIMIZER_API void meshopt_optimizeVertexCacheFifo(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, unsigned int cache_size); + +/** + * Overdraw optimizer + * Reorders indices to reduce the number of GPU vertex shader invocations and the pixel overdraw + * If index buffer contains multiple ranges for multiple draw calls, this function needs to be called on each range individually. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + * indices must contain index data that is the result of meshopt_optimizeVertexCache (*not* the original mesh indices!) + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * threshold indicates how much the overdraw optimizer can degrade vertex cache efficiency (1.05 = up to 5%) to reduce overdraw more efficiently + */ +MESHOPTIMIZER_API void meshopt_optimizeOverdraw(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float threshold); + +/** + * Vertex fetch cache optimizer + * Reorders vertices and changes indices to reduce the amount of GPU memory fetches during vertex processing + * Returns the number of unique vertices, which is the same as input vertex count unless some vertices are unused + * This function works for a single vertex stream; for multiple vertex streams, use meshopt_optimizeVertexFetchRemap + meshopt_remapVertexBuffer for each stream. + * + * destination must contain enough space for the resulting vertex buffer (vertex_count elements) + * indices is used both as an input and as an output index buffer + */ +MESHOPTIMIZER_API size_t meshopt_optimizeVertexFetch(void* destination, unsigned int* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size); + +/** + * Vertex fetch cache optimizer + * Generates vertex remap to reduce the amount of GPU memory fetches during vertex processing + * Returns the number of unique vertices, which is the same as input vertex count unless some vertices are unused + * The resulting remap table should be used to reorder vertex/index buffers using meshopt_remapVertexBuffer/meshopt_remapIndexBuffer + * + * destination must contain enough space for the resulting remap table (vertex_count elements) + */ +MESHOPTIMIZER_API size_t meshopt_optimizeVertexFetchRemap(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count); + +/** + * Index buffer encoder + * Encodes index data into an array of bytes that is generally much smaller (<1.5 bytes/triangle) and compresses better (<1 bytes/triangle) compared to original. + * Input index buffer must represent a triangle list. + * Returns encoded data size on success, 0 on error; the only error condition is if buffer doesn't have enough space + * For maximum efficiency the index buffer being encoded has to be optimized for vertex cache and vertex fetch first. + * + * buffer must contain enough space for the encoded index buffer (use meshopt_encodeIndexBufferBound to compute worst case size) + */ +MESHOPTIMIZER_API size_t meshopt_encodeIndexBuffer(unsigned char* buffer, size_t buffer_size, const unsigned int* indices, size_t index_count); +MESHOPTIMIZER_API size_t meshopt_encodeIndexBufferBound(size_t index_count, size_t vertex_count); + +/** + * Set index encoder format version (defaults to 1) + * This function is not thread safe and must not be called concurrently with meshopt_encodeIndexBuffer/meshopt_encodeIndexSequence. + * + * version must specify the data format version to encode; valid values are 0 (decodable by all library versions) and 1 (decodable by 0.14+) + */ +MESHOPTIMIZER_API void meshopt_encodeIndexVersion(int version); + +/** + * Index buffer decoder + * Decodes index data from an array of bytes generated by meshopt_encodeIndexBuffer + * Returns 0 if decoding was successful, and an error code otherwise + * The decoder is safe to use for untrusted input, but it may produce garbage data (e.g. out of range indices). + * + * destination must contain enough space for the resulting index buffer (index_count elements) + */ +MESHOPTIMIZER_API int meshopt_decodeIndexBuffer(void* destination, size_t index_count, size_t index_size, const unsigned char* buffer, size_t buffer_size); + +/** + * Get encoded index format version + * Returns format version of the encoded index buffer/sequence, or -1 if the buffer header is invalid + * Note that a non-negative value doesn't guarantee that the buffer will be decoded correctly if the input is malformed. + */ +MESHOPTIMIZER_API int meshopt_decodeIndexVersion(const unsigned char* buffer, size_t buffer_size); + +/** + * Index sequence encoder + * Encodes index sequence into an array of bytes that is generally smaller and compresses better compared to original. + * Input index sequence can represent arbitrary topology; for triangle lists meshopt_encodeIndexBuffer is likely to be better. + * Returns encoded data size on success, 0 on error; the only error condition is if buffer doesn't have enough space + * + * buffer must contain enough space for the encoded index sequence (use meshopt_encodeIndexSequenceBound to compute worst case size) + */ +MESHOPTIMIZER_API size_t meshopt_encodeIndexSequence(unsigned char* buffer, size_t buffer_size, const unsigned int* indices, size_t index_count); +MESHOPTIMIZER_API size_t meshopt_encodeIndexSequenceBound(size_t index_count, size_t vertex_count); + +/** + * Index sequence decoder + * Decodes index data from an array of bytes generated by meshopt_encodeIndexSequence + * Returns 0 if decoding was successful, and an error code otherwise + * The decoder is safe to use for untrusted input, but it may produce garbage data (e.g. out of range indices). + * + * destination must contain enough space for the resulting index sequence (index_count elements) + */ +MESHOPTIMIZER_API int meshopt_decodeIndexSequence(void* destination, size_t index_count, size_t index_size, const unsigned char* buffer, size_t buffer_size); + +/** + * Meshlet encoder + * Encodes meshlet data into an array of bytes that is generally smaller and compresses better compared to original. + * Returns encoded data size on success, 0 on error; the only error condition is if buffer doesn't have enough space + * This function encodes a single meshlet; when encoding multiple meshlets, additional headers may be necessary to store vertex/triangle count and encoded size. + * For maximum efficiency the meshlet being encoded should be optimized using meshopt_optimizeMeshletLevel with level 1+ (3 recommended); additionally, vertex reference data should be optimized for locality (fetch). + * + * buffer must contain enough space for the encoded meshlet (use meshopt_encodeMeshletBound to compute worst case size) + * vertices may be NULL, in which case vertex_count must be 0 and only triangle data is encoded + * vertex_count and triangle_count must be <= 256. + */ +MESHOPTIMIZER_API size_t meshopt_encodeMeshlet(unsigned char* buffer, size_t buffer_size, const unsigned int* vertices, size_t vertex_count, const unsigned char* triangles, size_t triangle_count); +MESHOPTIMIZER_API size_t meshopt_encodeMeshletBound(size_t max_vertices, size_t max_triangles); + +/** + * Meshlet decoder + * Decodes meshlet data from an array of bytes generated by meshopt_encodeMeshlet + * Returns 0 if decoding was successful, and an error code otherwise + * The decoder is safe to use for untrusted input, but it may produce garbage data. + * + * vertices must contain enough space for the resulting vertex data, aligned to 4 bytes (align(vertex_count * vertex_size, 4) bytes) + * vertex_size must be 2 (16-bit vertex references) or 4 (32-bit vertex references) + * triangles must contain enough space for the resulting triangle data, aligned to 4 bytes (align(triangle_count * triangle_size, 4) bytes) + * triangle_size must be 3 (8-bit triangle indices) or 4 (32-bit packed triangles, stored as (a) | (b << 8) | (c << 16)) + * vertex_count, triangle_count match those used during encoding exactly; buffer_size must be equal to the encoded size returned by meshopt_encodeMeshlet. + * vertices may be NULL, in which case vertex_count must be 0 and the meshlet must contain just triangle data + * + * When using "raw" decoding (meshopt_decodeMeshletRaw), both vertices and triangles should have available space further aligned to 16 bytes for efficient SIMD decoding. + */ +MESHOPTIMIZER_API int meshopt_decodeMeshlet(void* vertices, size_t vertex_count, size_t vertex_size, void* triangles, size_t triangle_count, size_t triangle_size, const unsigned char* buffer, size_t buffer_size); +MESHOPTIMIZER_API int meshopt_decodeMeshletRaw(unsigned int* vertices, size_t vertex_count, unsigned int* triangles, size_t triangle_count, const unsigned char* buffer, size_t buffer_size); + +/** + * Vertex buffer encoder + * Encodes vertex data into an array of bytes that is generally smaller and compresses better compared to original. + * Returns encoded data size on success, 0 on error; the only error condition is if buffer doesn't have enough space + * This function works for a single vertex stream; for multiple vertex streams, call meshopt_encodeVertexBuffer for each stream. + * Note that all vertex_size bytes of each vertex are encoded verbatim, including padding which should be zero-initialized. + * For maximum efficiency the vertex buffer being encoded has to be quantized and optimized for locality of reference (cache/fetch) first. + * + * buffer must contain enough space for the encoded vertex buffer (use meshopt_encodeVertexBufferBound to compute worst case size) + * vertex_size must be a multiple of 4 (and <= 256) + */ +MESHOPTIMIZER_API size_t meshopt_encodeVertexBuffer(unsigned char* buffer, size_t buffer_size, const void* vertices, size_t vertex_count, size_t vertex_size); +MESHOPTIMIZER_API size_t meshopt_encodeVertexBufferBound(size_t vertex_count, size_t vertex_size); + +/** + * Vertex buffer encoder + * Encodes vertex data just like meshopt_encodeVertexBuffer, but allows to override compression level. + * For compression level to take effect, the vertex encoding version must be set to 1. + * The default compression level implied by meshopt_encodeVertexBuffer is 2. + * + * buffer must contain enough space for the encoded vertex buffer (use meshopt_encodeVertexBufferBound to compute worst case size) + * vertex_size must be a multiple of 4 (and <= 256) + * level should be in the range [0, 3] with 0 being the fastest and 3 being the slowest and producing the best compression ratio. + * version should be -1 to use the default version (specified via meshopt_encodeVertexVersion), or 0/1 to override the version; per above, level won't take effect if version is 0. + */ +MESHOPTIMIZER_API size_t meshopt_encodeVertexBufferLevel(unsigned char* buffer, size_t buffer_size, const void* vertices, size_t vertex_count, size_t vertex_size, int level, int version); + +/** + * Set vertex encoder format version (defaults to 1) + * This function is not thread safe and must not be called concurrently with meshopt_encodeVertexBuffer/meshopt_encodeVertexBufferLevel. + * + * version must specify the data format version to encode; valid values are 0 (decodable by all library versions) and 1 (decodable by 0.23+) + */ +MESHOPTIMIZER_API void meshopt_encodeVertexVersion(int version); + +/** + * Vertex buffer decoder + * Decodes vertex data from an array of bytes generated by meshopt_encodeVertexBuffer + * Returns 0 if decoding was successful, and an error code otherwise + * The decoder is safe to use for untrusted input, but it may produce garbage data. + * + * destination must contain enough space for the resulting vertex buffer (vertex_count * vertex_size bytes) + * vertex_size must be a multiple of 4 (and <= 256) + */ +MESHOPTIMIZER_API int meshopt_decodeVertexBuffer(void* destination, size_t vertex_count, size_t vertex_size, const unsigned char* buffer, size_t buffer_size); + +/** + * Get encoded vertex format version + * Returns format version of the encoded vertex buffer, or -1 if the buffer header is invalid + * Note that a non-negative value doesn't guarantee that the buffer will be decoded correctly if the input is malformed. + */ +MESHOPTIMIZER_API int meshopt_decodeVertexVersion(const unsigned char* buffer, size_t buffer_size); + +/** + * Vertex buffer filters + * These functions can be used to filter output of meshopt_decodeVertexBuffer in-place. + * + * meshopt_decodeFilterOct decodes octahedral encoding of a unit vector with K-bit signed X/Y as an input; Z must store 1.0f. + * Each component is stored as an 8-bit or 16-bit normalized integer; stride must be equal to 4 or 8. W is preserved as is. + * + * meshopt_decodeFilterQuat decodes 3-component quaternion encoding with K-bit component encoding and a 2-bit component index indicating which component to reconstruct. + * Each component is stored as a 16-bit integer; stride must be equal to 8. + * + * meshopt_decodeFilterExp decodes exponential encoding of floating-point data with 8-bit exponent and 24-bit integer mantissa as 2^E*M. + * Each 32-bit component is decoded in isolation; stride must be divisible by 4. + * + * meshopt_decodeFilterColor decodes RGBA colors from YCoCg (+A) color encoding where RGB is converted to YCoCg space with K-bit component encoding, and A is stored using K-1 bits. + * Each component is stored as an 8-bit or 16-bit normalized integer; stride must be equal to 4 or 8. + */ +MESHOPTIMIZER_API void meshopt_decodeFilterOct(void* buffer, size_t count, size_t stride); +MESHOPTIMIZER_API void meshopt_decodeFilterQuat(void* buffer, size_t count, size_t stride); +MESHOPTIMIZER_API void meshopt_decodeFilterExp(void* buffer, size_t count, size_t stride); +MESHOPTIMIZER_API void meshopt_decodeFilterColor(void* buffer, size_t count, size_t stride); + +/** + * Vertex buffer filter encoders + * These functions can be used to encode data in a format that meshopt_decodeFilter can decode + * + * meshopt_encodeFilterOct encodes unit vectors with K-bit (2 <= K <= 16) signed X/Y as an output. + * Each component is stored as an 8-bit or 16-bit normalized integer; stride must be equal to 4 (K <= 8) or 8 (K <= 16). Z will store 1.0f, W is preserved as is. + * Input data must contain 4 floats for every vector (count*4 total). + * + * meshopt_encodeFilterQuat encodes unit quaternions with K-bit (4 <= K <= 16) component encoding. + * Each component is stored as a 16-bit integer; stride must be equal to 8. + * Input data must contain 4 floats for every quaternion (count*4 total). + * + * meshopt_encodeFilterExp encodes arbitrary (finite) floating-point data with 8-bit exponent and K-bit integer mantissa (1 <= K <= 24). + * Exponent can be shared between all components of a given vector as defined by stride or all values of a given component; stride must be divisible by 4 (and <= 256). + * Input data must contain stride/4 floats for every vector (count*stride/4 total). + * + * meshopt_encodeFilterColor encodes RGBA color data by converting RGB to YCoCg color space with K-bit (2 <= K <= 16) component encoding; A is stored using K-1 bits. + * Each component is stored as an 8-bit or 16-bit integer; stride must be equal to 4 (K <= 8) or 8 (K <= 16). + * Input data must contain 4 floats for every color (count*4 total). + */ +enum meshopt_EncodeExpMode +{ + /* When encoding exponents, use separate values for each component (maximum quality) */ + meshopt_EncodeExpSeparate, + /* When encoding exponents, use shared value for all components of each vector (better compression) */ + meshopt_EncodeExpSharedVector, + /* When encoding exponents, use shared value for each component of all vectors (best compression) */ + meshopt_EncodeExpSharedComponent, + /* When encoding exponents, use separate values for each component, but clamp to 0 (good quality if very small values are not important) */ + meshopt_EncodeExpClamped, +}; + +MESHOPTIMIZER_API void meshopt_encodeFilterOct(void* destination, size_t count, size_t stride, int bits, const float* data); +MESHOPTIMIZER_API void meshopt_encodeFilterQuat(void* destination, size_t count, size_t stride, int bits, const float* data); +MESHOPTIMIZER_API void meshopt_encodeFilterExp(void* destination, size_t count, size_t stride, int bits, const float* data, enum meshopt_EncodeExpMode mode); +MESHOPTIMIZER_API void meshopt_encodeFilterColor(void* destination, size_t count, size_t stride, int bits, const float* data); + +/** + * Simplification options + */ +enum +{ + /* Do not move vertices that are located on the topological border (vertices on triangle edges that don't have a paired triangle). Useful for simplifying portions of the larger mesh. */ + meshopt_SimplifyLockBorder = 1 << 0, + /* Improve simplification performance assuming input indices are a sparse subset of the mesh. Note that error becomes relative to subset extents. */ + meshopt_SimplifySparse = 1 << 1, + /* Treat error limit and resulting error as absolute instead of relative to mesh extents. */ + meshopt_SimplifyErrorAbsolute = 1 << 2, + /* Remove disconnected parts of the mesh during simplification incrementally, regardless of the topological restrictions inside components. */ + meshopt_SimplifyPrune = 1 << 3, + /* Produce more regular triangle sizes and shapes during simplification, at some cost to geometric and attribute quality. */ + meshopt_SimplifyRegularize = 1 << 4, + /* Allow collapses across attribute discontinuities, except for vertices that are tagged with meshopt_SimplifyVertex_Protect in vertex_lock. */ + meshopt_SimplifyPermissive = 1 << 5, + /* Produce more regular triangle sizes and shapes during simplification, at a small cost to geometric and attribute quality. */ + meshopt_SimplifyRegularizeLight = 1 << 6, + /* Experimental: Try to preserve fold lines between opposite-facing triangles, at a small performance cost. */ + meshopt_SimplifyPreserveFolds = 1 << 7, + /* Experimental: Clamp attribute error to match position error scale and avoid extreme error values in areas with high attribute variance. */ + meshopt_SimplifyErrorClamped = 1 << 8, +}; + +/** + * Simplification vertex flags/locks, for use in `vertex_lock` arrays in simplification APIs + */ +enum +{ + /* Do not move this vertex. */ + meshopt_SimplifyVertex_Lock = 1 << 0, + /* Protect attribute discontinuity at this vertex; must be used together with meshopt_SimplifyPermissive option. */ + meshopt_SimplifyVertex_Protect = 1 << 1, + /* Increase priority for this vertex, making it more likely that it's preserved during simplification. */ + meshopt_SimplifyVertex_Priority = 1 << 2, +}; + +/** + * Mesh simplifier + * Reduces the number of triangles in the mesh, attempting to preserve mesh appearance as much as possible + * The algorithm tries to preserve mesh topology and can stop short of the target goal based on topology constraints or target error. + * If not all attributes from the input mesh are needed, it's recommended to reindex the mesh without them prior to simplification. + * Returns the number of indices after simplification, with destination containing new index data + * + * The resulting index buffer references vertices from the original vertex buffer. + * If the original vertex data isn't needed, creating a compact vertex buffer using meshopt_optimizeVertexFetch is recommended. + * + * destination must contain enough space for the target index buffer, worst case is index_count elements (*not* target_index_count)! + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * target_error represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation (unless absolute error option is used) + * options must be a bitmask composed of meshopt_SimplifyX options; 0 is a safe default + * result_error can be NULL; when it's not NULL, it will contain the resulting (relative/absolute) error after simplification + */ +MESHOPTIMIZER_API size_t meshopt_simplify(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t target_index_count, float target_error, unsigned int options, float* result_error); + +/** + * Mesh simplifier with attribute metric + * Reduces the number of triangles in the mesh, attempting to preserve mesh appearance as much as possible. + * Similar to meshopt_simplify, but incorporates attribute values into the error metric used to prioritize simplification order. + * The algorithm tries to preserve mesh topology and can stop short of the target goal based on topology constraints or target error. + * If not all attributes from the input mesh are needed, it's recommended to reindex the mesh without them prior to simplification. + * Returns the number of indices after simplification, with destination containing new index data + * + * The resulting index buffer references vertices from the original vertex buffer. + * If the original vertex data isn't needed, creating a compact vertex buffer using meshopt_optimizeVertexFetch is recommended. + * Note that the number of attributes with non-zero weights affects memory requirements and running time. + * + * destination must contain enough space for the target index buffer, worst case is index_count elements (*not* target_index_count)! + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * vertex_attributes should have attribute_count floats for each vertex + * attribute_weights should have attribute_count floats in total; the weights determine relative priority of attributes between each other and wrt position + * attribute_count must be <= 32 + * vertex_lock can be NULL; when it's not NULL, it should have a value for each vertex composed of meshopt_SimplifyVertex_* flags + * target_error represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation (unless absolute error option is used) + * options must be a bitmask composed of meshopt_SimplifyX options; 0 is a safe default + * result_error can be NULL; when it's not NULL, it will contain the resulting (relative/absolute) error after simplification + */ +MESHOPTIMIZER_API size_t meshopt_simplifyWithAttributes(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_attributes, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options, float* result_error); + +/** + * Mesh simplifier with position/attribute update + * Reduces the number of triangles in the mesh, attempting to preserve mesh appearance as much as possible. + * Similar to meshopt_simplifyWithAttributes, but destructively updates positions and attribute values for optimal appearance. + * The algorithm tries to preserve mesh topology and can stop short of the target goal based on topology constraints or target error. + * If not all attributes from the input mesh are needed, it's recommended to reindex the mesh without them prior to simplification. + * Returns the number of indices after simplification, indices are destructively updated with new index data + * + * The updated index buffer references vertices from the original vertex buffer, however the vertex positions and attributes are updated in-place. + * Creating a compact vertex buffer using meshopt_optimizeVertexFetch is recommended; if the original vertex data is needed, it should be copied before simplification. + * Note that the number of attributes with non-zero weights affects memory requirements and running time. Attributes with zero weights are not updated. + * + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * vertex_attributes should have attribute_count floats for each vertex + * attribute_weights should have attribute_count floats in total; the weights determine relative priority of attributes between each other and wrt position + * attribute_count must be <= 32 + * vertex_lock can be NULL; when it's not NULL, it should have a value for each vertex composed of meshopt_SimplifyVertex_* flags + * target_error represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation (unless absolute error option is used) + * options must be a bitmask composed of meshopt_SimplifyX options; 0 is a safe default + * result_error can be NULL; when it's not NULL, it will contain the resulting (relative/absolute) error after simplification + */ +MESHOPTIMIZER_API size_t meshopt_simplifyWithUpdate(unsigned int* indices, size_t index_count, float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float* vertex_attributes, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options, float* result_error); + +/** + * Mesh simplifier (sloppy) + * Reduces the number of triangles in the mesh, sacrificing mesh appearance for simplification performance + * The algorithm doesn't preserve mesh topology but can stop short of the target goal based on target error. + * Returns the number of indices after simplification, with destination containing new index data + * The resulting index buffer references vertices from the original vertex buffer. + * If the original vertex data isn't needed, creating a compact vertex buffer using meshopt_optimizeVertexFetch is recommended. + * + * destination must contain enough space for the target index buffer, worst case is index_count elements (*not* target_index_count)! + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * vertex_lock can be NULL; when it's not NULL, it should have a value for each vertex; vertices that can't be moved should set 1 consistently for all indices with the same position + * target_error represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation; value range [0..1] + * result_error can be NULL; when it's not NULL, it will contain the resulting (relative) error after simplification + */ +MESHOPTIMIZER_API size_t meshopt_simplifySloppy(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const unsigned char* vertex_lock, size_t target_index_count, float target_error, float* result_error); + +/** + * Mesh simplifier (pruner) + * Reduces the number of triangles in the mesh by removing small isolated parts of the mesh + * Returns the number of indices after simplification, with destination containing new index data + * The resulting index buffer references vertices from the original vertex buffer. + * If the original vertex data isn't needed, creating a compact vertex buffer using meshopt_optimizeVertexFetch is recommended. + * + * destination must contain enough space for the target index buffer, worst case is index_count elements + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * target_error represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation; value range [0..1] + */ +MESHOPTIMIZER_API size_t meshopt_simplifyPrune(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float target_error); + +/** + * Point cloud simplifier + * Reduces the number of points in the cloud to reach the given target + * Returns the number of points after simplification, with destination containing new index data + * The resulting index buffer references vertices from the original vertex buffer. + * If the original vertex data isn't needed, creating a compact vertex buffer using meshopt_optimizeVertexFetch is recommended. + * + * destination must contain enough space for the target index buffer (target_vertex_count elements) + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * vertex_colors can be NULL; when it's not NULL, it should have float3 color in the first 12 bytes of each vertex + * color_weight determines relative priority of color wrt position; 1.0 is a safe default + */ +MESHOPTIMIZER_API size_t meshopt_simplifyPoints(unsigned int* destination, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_colors, size_t vertex_colors_stride, float color_weight, size_t target_vertex_count); + +/** + * Returns the error scaling factor used by the simplifier to convert between absolute and relative extents + * + * Absolute error must be *divided* by the scaling factor before passing it to meshopt_simplify as target_error + * Relative error returned by meshopt_simplify via result_error must be *multiplied* by the scaling factor to get absolute error. + */ +MESHOPTIMIZER_API float meshopt_simplifyScale(const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +/** + * Mesh stripifier + * Converts a previously vertex cache optimized triangle list to triangle strip, stitching strips using restart index or degenerate triangles + * Returns the number of indices in the resulting strip, with destination containing new index data + * For maximum efficiency the index buffer being converted has to be optimized for vertex cache first. + * Using restart indices can result in ~10% smaller index buffers, but on some GPUs restart indices may result in decreased performance. + * + * destination must contain enough space for the target index buffer, worst case can be computed with meshopt_stripifyBound + * restart_index should be 0xffff or 0xffffffff depending on index size, or 0 to use degenerate triangles + */ +MESHOPTIMIZER_API size_t meshopt_stripify(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, unsigned int restart_index); +MESHOPTIMIZER_API size_t meshopt_stripifyBound(size_t index_count); + +/** + * Mesh unstripifier + * Converts a triangle strip to a triangle list + * Returns the number of indices in the resulting list, with destination containing new index data + * + * destination must contain enough space for the target index buffer, worst case can be computed with meshopt_unstripifyBound + */ +MESHOPTIMIZER_API size_t meshopt_unstripify(unsigned int* destination, const unsigned int* indices, size_t index_count, unsigned int restart_index); +MESHOPTIMIZER_API size_t meshopt_unstripifyBound(size_t index_count); + +struct meshopt_VertexCacheStatistics +{ + unsigned int vertices_transformed; + unsigned int warps_executed; + float acmr; /* transformed vertices / triangle count; best case 0.5, worst case 3.0, optimum depends on topology */ + float atvr; /* transformed vertices / vertex count; best case 1.0, worst case 6.0, optimum is 1.0 (each vertex is transformed once) */ +}; + +/** + * Vertex transform cache analyzer + * Returns cache hit statistics using a simplified FIFO model + * Results may not match actual GPU performance + */ +MESHOPTIMIZER_API struct meshopt_VertexCacheStatistics meshopt_analyzeVertexCache(const unsigned int* indices, size_t index_count, size_t vertex_count, unsigned int cache_size, unsigned int warp_size, unsigned int primgroup_size); + +struct meshopt_VertexFetchStatistics +{ + unsigned int bytes_fetched; + float overfetch; /* fetched bytes / vertex buffer size; best case 1.0 (each byte is fetched once) */ +}; + +/** + * Vertex fetch cache analyzer + * Returns cache hit statistics using a simplified direct mapped model + * Results may not match actual GPU performance + */ +MESHOPTIMIZER_API struct meshopt_VertexFetchStatistics meshopt_analyzeVertexFetch(const unsigned int* indices, size_t index_count, size_t vertex_count, size_t vertex_size); + +struct meshopt_OverdrawStatistics +{ + unsigned int pixels_covered; + unsigned int pixels_shaded; + float overdraw; /* shaded pixels / covered pixels; best case 1.0 */ +}; + +/** + * Overdraw analyzer + * Returns overdraw statistics using a software rasterizer + * Results may not match actual GPU performance + * + * vertex_positions should have float3 position in the first 12 bytes of each vertex + */ +MESHOPTIMIZER_API struct meshopt_OverdrawStatistics meshopt_analyzeOverdraw(const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +struct meshopt_CoverageStatistics +{ + float coverage[3]; + float extent; /* viewport size in mesh coordinates */ +}; + +/** + * Coverage analyzer + * Returns coverage statistics (ratio of viewport pixels covered from each axis) using a software rasterizer + * + * vertex_positions should have float3 position in the first 12 bytes of each vertex + */ +MESHOPTIMIZER_API struct meshopt_CoverageStatistics meshopt_analyzeCoverage(const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +/** + * Meshlet is a small mesh cluster (subset) that consists of: + * - triangles, an 8-bit micro triangle (index) buffer, that for each triangle specifies three local vertices to use; + * - vertices, a 32-bit vertex indirection buffer, that for each local vertex specifies which mesh vertex to fetch vertex attributes from. + * + * For efficiency, meshlet triangles and vertices are packed into two large arrays; this structure contains offsets and counts to access the data. + */ +struct meshopt_Meshlet +{ + /* offsets within meshlet_vertices and meshlet_triangles arrays with meshlet data */ + unsigned int vertex_offset; + unsigned int triangle_offset; + + /* number of vertices and triangles used in the meshlet; data is stored in consecutive range [offset..offset+count) for vertices and [offset..offset+count*3) for triangles */ + unsigned int vertex_count; + unsigned int triangle_count; +}; + +/** + * Meshlet builder + * Splits the mesh into a set of meshlets where each meshlet has a micro index buffer indexing into meshlet vertices that refer to the original vertex buffer + * The resulting data can be used to render meshes using NVidia programmable mesh shading pipeline, or in other cluster-based renderers. + * When targeting mesh shading hardware, for maximum efficiency meshlets should be further optimized using meshopt_optimizeMeshlet. + * When using buildMeshlets, vertex positions need to be provided to minimize the size of the resulting clusters. + * When using buildMeshletsScan, for maximum efficiency the index buffer being converted has to be optimized for vertex cache first. + * + * meshlets must contain enough space for all meshlets, worst case size can be computed with meshopt_buildMeshletsBound + * meshlet_vertices must contain enough space for all meshlets, worst case is index_count elements (*not* vertex_count!) + * meshlet_triangles must contain enough space for all meshlets, worst case is index_count elements + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * max_vertices and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512) + * cone_weight should be set to 0 when cone culling is not used, and a value between 0 and 1 otherwise to balance between cluster size and cone culling efficiency + */ +MESHOPTIMIZER_API size_t meshopt_buildMeshlets(struct meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t max_triangles, float cone_weight); +MESHOPTIMIZER_API size_t meshopt_buildMeshletsScan(struct meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const unsigned int* indices, size_t index_count, size_t vertex_count, size_t max_vertices, size_t max_triangles); +MESHOPTIMIZER_API size_t meshopt_buildMeshletsBound(size_t index_count, size_t max_vertices, size_t max_triangles); + +/** + * Meshlet builder with flexible cluster sizes + * Splits the mesh into a set of meshlets, similarly to meshopt_buildMeshlets, but allows to specify minimum and maximum number of triangles per meshlet. + * Clusters between min and max triangle counts are split when the cluster size would have exceeded the expected cluster size by more than split_factor. + * + * meshlets must contain enough space for all meshlets, worst case size can be computed with meshopt_buildMeshletsBound using min_triangles (*not* max!) + * meshlet_vertices must contain enough space for all meshlets, worst case is index_count elements (*not* vertex_count!) + * meshlet_triangles must contain enough space for all meshlets, worst case is index_count elements + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * max_vertices, min_triangles and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512; min_triangles <= max_triangles) + * cone_weight should be set to 0 when cone culling is not used, and a value between 0 and 1 otherwise to balance between cluster size and cone culling efficiency + * split_factor should be set to a non-negative value; when greater than 0, clusters that have large bounds may be split unless they are under the min_triangles threshold + */ +MESHOPTIMIZER_API size_t meshopt_buildMeshletsFlex(struct meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t min_triangles, size_t max_triangles, float cone_weight, float split_factor); + +/** + * Meshlet builder that produces clusters optimized for raytracing + * Splits the mesh into a set of meshlets, similarly to meshopt_buildMeshlets, but optimizes cluster subdivision for raytracing and allows to specify minimum and maximum number of triangles per meshlet. + * + * meshlets must contain enough space for all meshlets, worst case size can be computed with meshopt_buildMeshletsBound using min_triangles (*not* max!) + * meshlet_vertices must contain enough space for all meshlets, worst case is index_count elements (*not* vertex_count!) + * meshlet_triangles must contain enough space for all meshlets, worst case is index_count elements + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * max_vertices, min_triangles and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512; min_triangles <= max_triangles) + * fill_weight allows to prioritize clusters that are closer to maximum size at some cost to SAH quality; 0.5 is a safe default + */ +MESHOPTIMIZER_API size_t meshopt_buildMeshletsSpatial(struct meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t min_triangles, size_t max_triangles, float fill_weight); + +/** + * Meshlet optimizer + * Reorders meshlet vertices and triangles to maximize locality which can improve rasterizer throughput or ray tracing performance when using fast-build modes. + * + * meshlet_triangles and meshlet_vertices must refer to meshlet data; when buildMeshlets* is used, these need to be computed from meshlet's vertex_offset and triangle_offset + * triangle_count and vertex_count must not exceed implementation limits (vertex_count <= 256, triangle_count <= 512) + */ +MESHOPTIMIZER_API void meshopt_optimizeMeshlet(unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, size_t triangle_count, size_t vertex_count); + +/** + * Meshlet optimizer + * Reorders meshlet vertices and triangles to maximize locality, with higher levels resulting in smaller compressed size at the cost of optimization time. + * At level 0 the result is equivalent to meshopt_optimizeMeshlet; levels >= 1 may rotate triangle corners to improve compression (which can change provoking vertex and affect OMM data). + * + * level should be in the range [0, 9] with 0 equivalent to meshopt_optimizeMeshlet and 9 being the slowest; the sweet spot for compression ratio is around 3 + */ +MESHOPTIMIZER_API void meshopt_optimizeMeshletLevel(unsigned int* meshlet_vertices, size_t vertex_count, unsigned char* meshlet_triangles, size_t triangle_count, int level); + +struct meshopt_Bounds +{ + /* bounding sphere, useful for frustum and occlusion culling */ + float center[3]; + float radius; + + /* normal cone, useful for backface culling */ + float cone_apex[3]; + float cone_axis[3]; + float cone_cutoff; /* = cos(angle/2) */ + + /* normal cone axis and cutoff, stored in 8-bit SNORM format; decode using x/127.0 */ + signed char cone_axis_s8[3]; + signed char cone_cutoff_s8; +}; + +/** + * Cluster bounds generator + * Creates bounding volumes that can be used for frustum, backface and occlusion culling. + * + * For backface culling with orthographic projection, use the following formula to reject backfacing clusters: + * dot(view, cone_axis) >= cone_cutoff + * + * For perspective projection, you can use the formula that needs cone apex in addition to axis & cutoff: + * dot(normalize(cone_apex - camera_position), cone_axis) >= cone_cutoff + * + * Alternatively, you can use the formula that doesn't need cone apex and uses bounding sphere instead: + * dot(normalize(center - camera_position), cone_axis) >= cone_cutoff + radius / length(center - camera_position) + * or an equivalent formula that doesn't have a singularity at center = camera_position: + * dot(center - camera_position, cone_axis) >= cone_cutoff * length(center - camera_position) + radius + * + * The formula that uses the apex is slightly more accurate but needs the apex; if you are already using bounding sphere + * to do frustum/occlusion culling, the formula that doesn't use the apex may be preferable (for derivation see + * Real-Time Rendering 4th Edition, section 19.3). + * + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * vertex_count should specify the number of vertices in the entire mesh, not cluster or meshlet + * indices should have at most 256 unique vertex indices + * index_count/3 and triangle_count must not exceed implementation limits (<= 512) + */ +MESHOPTIMIZER_API struct meshopt_Bounds meshopt_computeClusterBounds(const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); +MESHOPTIMIZER_API struct meshopt_Bounds meshopt_computeMeshletBounds(const unsigned int* meshlet_vertices, const unsigned char* meshlet_triangles, size_t triangle_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +/** + * Sphere bounds generator + * Creates bounding sphere around a set of points or a set of spheres; returns the center and radius of the sphere, with other fields of the result set to 0. + * + * positions should have float3 position in the first 12 bytes of each element + * radii can be NULL; when it's not NULL, it should have a non-negative float radius in the first 4 bytes of each element + */ +MESHOPTIMIZER_API struct meshopt_Bounds meshopt_computeSphereBounds(const float* positions, size_t count, size_t positions_stride, const float* radii, size_t radii_stride); + +/** + * Extract meshlet-local vertex and triangle indices from absolute cluster indices. + * Fills triangles[] and vertices[] such that vertices[triangles[i]] == indices[i], and returns the number of unique vertices. + * + * vertices must contain enough space for the resulting references (up to 256 elements) + * triangles must contain enough space for local indices (index_count elements) + * indices should have at most 256 unique vertex indices + * index_count/3 must not exceed implementation limits (<= 512) + */ +MESHOPTIMIZER_API size_t meshopt_extractMeshletIndices(unsigned int* vertices, unsigned char* triangles, const unsigned int* indices, size_t index_count); + +/** + * Cluster partitioner + * Partitions clusters into groups of similar size, prioritizing grouping clusters that share vertices or are close to each other. + * When vertex positions are not provided, only clusters that share vertices will be grouped together, which may result in small partitions for some inputs. + * + * destination must contain enough space for the resulting partition data (cluster_count elements) + * destination[i] will contain the partition id for cluster i, with the total number of partitions returned by the function + * cluster_indices should have the vertex indices referenced by each cluster, stored sequentially + * cluster_index_counts should have the number of indices in each cluster; sum of all cluster_index_counts must be equal to total_index_count + * vertex_positions can be NULL; when it's not NULL, it should have float3 position in the first 12 bytes of each vertex + * target_partition_size is a target size for each partition, in clusters; the resulting partitions may be smaller or larger (up to target + target/3) + */ +MESHOPTIMIZER_API size_t meshopt_partitionClusters(unsigned int* destination, const unsigned int* cluster_indices, size_t total_index_count, const unsigned int* cluster_index_counts, size_t cluster_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t target_partition_size); + +/** + * Spatial sorter + * Generates a remap table that can be used to reorder points for spatial locality. + * Resulting remap table maps old vertices to new vertices and can be used in meshopt_remapVertexBuffer. + * + * destination must contain enough space for the resulting remap table (vertex_count elements) + * vertex_positions should have float3 position in the first 12 bytes of each vertex + */ +MESHOPTIMIZER_API void meshopt_spatialSortRemap(unsigned int* destination, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +/** + * Spatial sorter + * Reorders triangles for spatial locality, and generates a new index buffer. The resulting index buffer can be used with other functions like optimizeVertexCache. + * + * destination must contain enough space for the resulting index buffer (index_count elements) + * vertex_positions should have float3 position in the first 12 bytes of each vertex + */ +MESHOPTIMIZER_API void meshopt_spatialSortTriangles(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); + +/** + * Spatial clusterizer + * Reorders points into clusters optimized for spatial locality, and generates a new index buffer. + * Ensures the output can be split into cluster_size chunks where each chunk has good positional locality. Only the last chunk will be smaller than cluster_size. + * + * destination must contain enough space for the resulting index buffer (vertex_count elements) + * vertex_positions should have float3 position in the first 12 bytes of each vertex + */ +MESHOPTIMIZER_API void meshopt_spatialClusterPoints(unsigned int* destination, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t cluster_size); + +/** + * Opacity micromap generator (measure) + * Computes a subdivision level for each input triangle, as well as deduplicating the triangles that reference the same UVs to reduce rasterization requests. + * Returns the number of OMM entries. + * + * levels and sources must contain enough space for the worst case output (index_count/3 elements, one per resulting OMM entry) + * levels[i] will contain the subdivision level for entry i, with the total number of entries returned by the function; each entry should be rasterized from triangle index sources[i] + * omm_indices must contain enough space for the resulting OMM indices (index_count/3 elements, one per triangle) + * vertex_uvs should have float2 texture coordinate in the first 8 bytes of each vertex + * max_level specifies the maximum subdivision level (0..12) + * target_edge can be 0; when >0, triangle subdivision is adaptive and targets target_edge^2 texel area + */ +MESHOPTIMIZER_API size_t meshopt_opacityMapMeasure(unsigned char* levels, unsigned int* sources, int* omm_indices, const unsigned int* indices, size_t index_count, const float* vertex_uvs, size_t vertex_count, size_t vertex_uvs_stride, unsigned int texture_width, unsigned int texture_height, int max_level, float target_edge); + +/** + * Opacity micromap generator (rasterize) + * Rasterizes opacity state for a single triangle entry by sampling the alpha texture, using bilinear filtering and 0.5 alpha cutoff. + * + * result should contain enough space for the output opacity data (which can be computed using meshopt_opacityMapEntrySize) + * level specifies the subdivision level (0..12) + * states should be 2 for 2-state format (opaque/transparent) and 4 for 4-state format (opaque/transparent/unknown) + * uv0/uv1/uv2 should refer to a float2 texture coordinate for each triangle corner; note that micromap data is sensitive to the corner order + * texture_data should point to the alpha channel of the first pixel, encoded as UNORM8 + * texture_stride specifies the distance in bytes between consecutive pixels, e.g. 4 for RGBA input + * texture_pitch specifies the distance in bytes between consecutive rows, e.g. 4*texture_width for tightly packed RGBA input + */ +MESHOPTIMIZER_API void meshopt_opacityMapRasterize(unsigned char* result, int level, int states, const float* uv0, const float* uv1, const float* uv2, const unsigned char* texture_data, size_t texture_stride, size_t texture_pitch, unsigned int texture_width, unsigned int texture_height); +MESHOPTIMIZER_API size_t meshopt_opacityMapEntrySize(int level, int states); + +/** + * Opacity micromap generator (compact) + * Compacts and deduplicates opacity data, merging identical micromap entries and replacing micromap states with special indices (-4..-1) when possible. + * Returns the number of OMM entries after compaction; the data array should be trimmed using the last offset/size. + * + * data should contain opacity data for all input/output entries + * levels should contain subdivision levels for all input/output entries + * offsets should contain offset into data[] for each entry + * levels[i] and offsets[i] will be updated with post-compaction level/offset for entry i, with the total number of entries returned by the function + * omm_indices should contain indices into the original OMM data, and will be updated with a new index or a special index (-4..-1) when possible + * states should be 2 for 2-state format (opaque/transparent) and 4 for 4-state format (opaque/transparent/unknown) + */ +MESHOPTIMIZER_API size_t meshopt_opacityMapCompact(unsigned char* data, size_t data_size, unsigned char* levels, unsigned int* offsets, size_t omm_count, int* omm_indices, size_t triangle_count, int states); + +/** + * Tangent generation options + */ +enum +{ + /* Produce tangents compatible with MikkTSpace (same weighting and fallbacks) at the cost of reduced quality. Not recommended unless normal maps are baked. */ + meshopt_TangentCompatible = 1 << 0, + /* For vertices only connected to degenerate triangles, output zero tangents instead of an arbitrary fallback. */ + meshopt_TangentZeroFallback = 1 << 1, +}; + +/** + * Tangent space generator + * Computes per-corner tangent vectors; for each corner, computes normalized tangent vector (xyz) and orientation (w, +/-1). + * Bitangent can be reconstructed via cross(normal, tangent.xyz) * tangent.w. + * To apply tangents to the mesh, either deindex and reindex it with the tangent stream, or copy tangents to existing vertex data while duplicating + * vertices with different tangent vectors (e.g. on UV mirror seams). + * Input can be indexed or unindexed (indices=NULL); this does not affect the resulting tangents, but indexed inputs are faster to process. + * + * result must contain enough space for the output tangent data (index_count*4 elements) + * indices can be NULL if the input is unindexed + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * vertex_normals should have unit float3 normal in the first 12 bytes of each vertex + * vertex_uvs should have float2 texture coordinate in the first 8 bytes of each vertex + */ +MESHOPTIMIZER_API void meshopt_generateTangents(float* result, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_normals, size_t vertex_normals_stride, const float* vertex_uvs, size_t vertex_uvs_stride, unsigned int options); + +/** + * Experimental: Normal generator + * Computes per-corner normal vectors; normals are averaged across soft edges, using crease angle to determine which edges are hard. + * To apply normals to the mesh, either deindex and reindex it with the normal stream, or copy normals to existing vertex data while duplicating + * vertices with different normal vectors (on normal seams). + * Input can be indexed or unindexed (indices=NULL); this does not affect the resulting normals, but indexed inputs are faster to process. + * + * result must contain enough space for the output normal data (index_count*3 elements) + * indices can be NULL if the input is unindexed + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * crease_angle specifies the angle in radians below which normals are averaged across edges + * smoothing specifies the amount of smoothing to apply to resulting normals as a post-process; larger smoothing values take longer, with the recommended range being [0..5]. + */ +MESHOPTIMIZER_EXPERIMENTAL void meshopt_generateNormals(float* result, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float crease_angle, float smoothing); + +/** + * Experimental: Remeshing flags + */ +enum +{ + /* Produce a two-sided shell that wraps around surfaces of the original mesh, instead of a solid mesh. */ + meshopt_RemeshShell = 1 << 0, + /* Compute optimal output positions that approximate the original surface as closely as possible. */ + meshopt_RemeshSolve = 1 << 1, +}; + +/** + * Experimental: Voxel remesher + * Generates a new mesh that approximates or refines the original mesh, attempting to preserve mesh appearance at a given voxel resolution. + * The original topology is not preserved; features that are closer than the voxel size may be merged, and small gaps in the original mesh may be closed. + * Returns the number of triangles in the new mesh, with destination containing a vertex position for each triangle corner. + * + * destination can be NULL; when it's not NULL, it must contain enough space for the resulting triangle buffer (max_triangle_count * 3 vertices, 3 floats per vertex) + * max_triangle_count is the number of triangles that can be written; when destination is NULL or insufficiently large, the returned value is an upper bound on the number of triangles + * vertex_positions should have float3 position in the first 12 bytes of each vertex + * resolution is the dimension of the internal voxel grid and should be in the range [4, 256] + * options must be a bitmask composed of meshopt_RemeshX options; 0 is a safe default + */ +MESHOPTIMIZER_EXPERIMENTAL size_t meshopt_remesh(float* destination, size_t max_triangle_count, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, int resolution, unsigned int options); + +/** + * Quantize a float into half-precision (as defined by IEEE-754 fp16) floating point value + * Generates +-inf for overflow, preserves NaN, flushes denormals to zero, rounds to nearest + * Representable magnitude range: [6e-5; 65504] + * Maximum relative reconstruction error: 5e-4 + */ +MESHOPTIMIZER_API unsigned short meshopt_quantizeHalf(float v); + +/** + * Quantize a float into a floating point value with a limited number of significant mantissa bits, preserving the IEEE-754 fp32 binary representation + * Preserves infinities/NaN, flushes denormals to zero, rounds to nearest + * Assumes N is in a valid mantissa precision range, which is 1..23 + */ +MESHOPTIMIZER_API float meshopt_quantizeFloat(float v, int N); + +/** + * Reverse quantization of a half-precision (as defined by IEEE-754 fp16) floating point value + * Preserves Inf/NaN, flushes denormals to zero + */ +MESHOPTIMIZER_API float meshopt_dequantizeHalf(unsigned short h); + +/** + * Compute shared exponent suitable for mesh/cluster position quantization + * Given mesh or cluster bounds, compute a shared exponent that can be used to quantize any position inside the bounds to a 24-bit integer grid. + * The resulting output can be stored as a compact bit-stream to be decoded directly in shaders, or to be used as an input to RT BVH builders, + * for example via D3D12_VERTEX_FORMAT_COMPRESSED1 in DXR2 (max_bits=16). + * + * To quantize positions, compute: + * scale = pow(2, exponent) + * iv = int(round(v / scale)) + * The resulting integer can be stored as signed 24-bit, or as an unsigned offset from a signed 24-bit anchor value, shared between all positions. + * + * minv/maxv specify the axis-aligned bounding box of the mesh or cluster; each should refer to a float3 value + * min_exp specifies the minimum value for the returned exponent, limiting precision to reduce size; e.g. min_exp = -10 limits precision to 1mm given metric units + * max_bits specifies the maximum allowed number of bits for the quantized integer range (offset from anchor is an unsigned integer up to 2^max_bits-1) + */ +MESHOPTIMIZER_API int meshopt_computePositionExponent(const float* minv, const float* maxv, int min_exp, int max_bits); + +/** + * Set allocation callbacks + * These callbacks will be used instead of the default operator new/operator delete for all temporary allocations in the library. + * Note that all algorithms only allocate memory for temporary use. + * allocate/deallocate are always called in a stack-like order - last pointer to be allocated is deallocated first. + * This function is not thread safe and must not be called concurrently with any other meshopt_ function. + * + * In shared library builds, allocations from templated index wrappers in this header will only be redirected if MESHOPTIMIZER_ALLOC_EXPORT is defined. + */ +MESHOPTIMIZER_API void meshopt_setAllocator(void* (MESHOPTIMIZER_ALLOC_CALLCONV* allocate)(size_t), void (MESHOPTIMIZER_ALLOC_CALLCONV* deallocate)(void*)); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +/* Quantization into fixed point normalized formats; these are only available as inline C++ functions */ +#ifdef __cplusplus +/** + * Quantize a float in [0..1] range into an N-bit fixed point unorm value + * Assumes reconstruction function (q / (2^N-1)), which is the case for fixed-function normalized fixed point conversion + * Maximum reconstruction error: 1/2^(N+1) + */ +inline int meshopt_quantizeUnorm(float v, int N); + +/** + * Quantize a float in [-1..1] range into an N-bit fixed point snorm value + * Assumes reconstruction function (q / (2^(N-1)-1)), which is the case for fixed-function normalized fixed point conversion (except early OpenGL versions) + * Maximum reconstruction error: 1/2^N + */ +inline int meshopt_quantizeSnorm(float v, int N); +#endif + +/** + * C++ template interface + * + * These functions mirror the C interface the library provides, providing template-based overloads so that + * the caller can use an arbitrary type for the index data, both for input and output. + * When the supplied type is the same size as that of unsigned int, the wrappers are zero-cost; when it's not, + * the wrappers end up allocating memory and copying index data to convert from one type to another. + */ +#if defined(__cplusplus) && !defined(MESHOPTIMIZER_NO_WRAPPERS) +template +inline size_t meshopt_generateVertexRemap(unsigned int* destination, const T* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size); +template +inline size_t meshopt_generateVertexRemapMulti(unsigned int* destination, const T* indices, size_t index_count, size_t vertex_count, const meshopt_Stream* streams, size_t stream_count); +template +inline size_t meshopt_generateVertexRemapCustom(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, F callback); +template +inline size_t meshopt_generateVertexRemapCustom(unsigned int* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, F callback); +template +inline void meshopt_remapIndexBuffer(T* destination, const T* indices, size_t index_count, const unsigned int* remap); +template +inline size_t meshopt_filterIndexBuffer(T* destination, const T* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size, size_t vertex_stride); +template +inline size_t meshopt_filterIndexBufferMulti(T* destination, const T* indices, size_t index_count, size_t vertex_count, const struct meshopt_Stream* streams, size_t stream_count); +template +inline void meshopt_generateShadowIndexBuffer(T* destination, const T* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size, size_t vertex_stride); +template +inline void meshopt_generateShadowIndexBufferMulti(T* destination, const T* indices, size_t index_count, size_t vertex_count, const meshopt_Stream* streams, size_t stream_count); +template +inline void meshopt_generateAdjacencyIndexBuffer(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); +template +inline void meshopt_generateTessellationIndexBuffer(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); +template +inline size_t meshopt_generateProvokingIndexBuffer(T* destination, unsigned int* reorder, const T* indices, size_t index_count, size_t vertex_count); +template +inline void meshopt_optimizeVertexCache(T* destination, const T* indices, size_t index_count, size_t vertex_count); +template +inline void meshopt_optimizeVertexCacheStrip(T* destination, const T* indices, size_t index_count, size_t vertex_count); +template +inline void meshopt_optimizeVertexCacheFifo(T* destination, const T* indices, size_t index_count, size_t vertex_count, unsigned int cache_size); +template +inline void meshopt_optimizeOverdraw(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float threshold); +template +inline size_t meshopt_optimizeVertexFetchRemap(unsigned int* destination, const T* indices, size_t index_count, size_t vertex_count); +template +inline size_t meshopt_optimizeVertexFetch(void* destination, T* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size); +template +inline size_t meshopt_encodeIndexBuffer(unsigned char* buffer, size_t buffer_size, const T* indices, size_t index_count); +template +inline int meshopt_decodeIndexBuffer(T* destination, size_t index_count, const unsigned char* buffer, size_t buffer_size); +template +inline size_t meshopt_encodeIndexSequence(unsigned char* buffer, size_t buffer_size, const T* indices, size_t index_count); +template +inline int meshopt_decodeIndexSequence(T* destination, size_t index_count, const unsigned char* buffer, size_t buffer_size); +template +inline int meshopt_decodeMeshlet(V* vertices, size_t vertex_count, T* triangles, size_t triangle_count, const unsigned char* buffer, size_t buffer_size); +inline size_t meshopt_encodeVertexBufferLevel(unsigned char* buffer, size_t buffer_size, const void* vertices, size_t vertex_count, size_t vertex_size, int level); +template +inline size_t meshopt_simplify(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t target_index_count, float target_error, unsigned int options = 0, float* result_error = NULL); +template +inline size_t meshopt_simplifyWithAttributes(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_attributes, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options = 0, float* result_error = NULL); +template +inline size_t meshopt_simplifyWithUpdate(T* indices, size_t index_count, float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float* vertex_attributes, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options = 0, float* result_error = NULL); +template +inline size_t meshopt_simplifySloppy(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t target_index_count, float target_error, float* result_error = NULL); +template +inline size_t meshopt_simplifySloppy(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const unsigned char* vertex_lock, size_t target_index_count, float target_error, float* result_error = NULL); +template +inline size_t meshopt_simplifyPrune(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float target_error); +template +inline size_t meshopt_stripify(T* destination, const T* indices, size_t index_count, size_t vertex_count, T restart_index); +template +inline size_t meshopt_unstripify(T* destination, const T* indices, size_t index_count, T restart_index); +template +inline meshopt_VertexCacheStatistics meshopt_analyzeVertexCache(const T* indices, size_t index_count, size_t vertex_count, unsigned int cache_size, unsigned int warp_size, unsigned int primgroup_size); +template +inline meshopt_VertexFetchStatistics meshopt_analyzeVertexFetch(const T* indices, size_t index_count, size_t vertex_count, size_t vertex_size); +template +inline meshopt_OverdrawStatistics meshopt_analyzeOverdraw(const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); +template +inline meshopt_CoverageStatistics meshopt_analyzeCoverage(const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); +template +inline size_t meshopt_buildMeshlets(meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t max_triangles, float cone_weight); +template +inline size_t meshopt_buildMeshletsScan(meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const T* indices, size_t index_count, size_t vertex_count, size_t max_vertices, size_t max_triangles); +template +inline size_t meshopt_buildMeshletsFlex(meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t min_triangles, size_t max_triangles, float cone_weight, float split_factor); +template +inline size_t meshopt_buildMeshletsSpatial(meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t min_triangles, size_t max_triangles, float fill_weight); +template +inline meshopt_Bounds meshopt_computeClusterBounds(const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); +template +inline size_t meshopt_partitionClusters(unsigned int* destination, const T* cluster_indices, size_t total_index_count, const unsigned int* cluster_index_counts, size_t cluster_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t target_partition_size); +template +inline void meshopt_spatialSortTriangles(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride); +template +inline void meshopt_generateTangents(float* result, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_normals, size_t vertex_normals_stride, const float* vertex_uvs, size_t vertex_uvs_stride, unsigned int options = 0); +template +inline void meshopt_generateNormals(float* result, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float crease_angle, float smoothing = 0.f); +template +inline size_t meshopt_remesh(float* destination, size_t max_triangle_count, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, int resolution, unsigned int options = 0); +#endif + +/* Inline implementation */ +#ifdef __cplusplus +inline int meshopt_quantizeUnorm(float v, int N) +{ + const float scale = float((1 << N) - 1); + + v = (v >= 0) ? v : 0; + v = (v <= 1) ? v : 1; + + return int(v * scale + 0.5f); +} + +inline int meshopt_quantizeSnorm(float v, int N) +{ + const float scale = float((1 << (N - 1)) - 1); + + float round = (v >= 0 ? 0.5f : -0.5f); + + v = (v >= -1) ? v : -1; + v = (v <= +1) ? v : +1; + + return int(v * scale + round); +} +#endif + +/* Internal implementation helpers */ +#ifdef __cplusplus +class meshopt_Allocator +{ +public: + struct Storage + { + void* (MESHOPTIMIZER_ALLOC_CALLCONV* allocate)(size_t); + void (MESHOPTIMIZER_ALLOC_CALLCONV* deallocate)(void*); + }; + +#ifdef MESHOPTIMIZER_ALLOC_EXPORT + MESHOPTIMIZER_API static Storage& storage(); +#else + static Storage& storage() + { + static Storage s = {::operator new, ::operator delete }; + return s; + } +#endif + + meshopt_Allocator() + : blocks() + , count(0) + { + } + + ~meshopt_Allocator() + { + for (size_t i = count; i > 0; --i) + storage().deallocate(blocks[i - 1]); + } + + template + T* allocate(size_t size) + { + assert(count < sizeof(blocks) / sizeof(blocks[0])); + T* result = static_cast(storage().allocate(size > size_t(-1) / sizeof(T) ? size_t(-1) : size * sizeof(T))); + blocks[count++] = result; + return result; + } + + void deallocate(void* ptr) + { + assert(count > 0 && blocks[count - 1] == ptr); + storage().deallocate(ptr); + count--; + } + +private: + void* blocks[24]; + size_t count; +}; +#endif + +/* Inline implementation for C++ templated wrappers */ +#if defined(__cplusplus) && !defined(MESHOPTIMIZER_NO_WRAPPERS) +template +struct meshopt_IndexAdapter; + +template +struct meshopt_IndexAdapter +{ + T* result; + unsigned int* data; + size_t count; + + meshopt_IndexAdapter(T* result_, const T* input, size_t count_) + : result(result_) + , data(NULL) + , count(count_) + { + size_t size = count > size_t(-1) / sizeof(unsigned int) ? size_t(-1) : count * sizeof(unsigned int); + + data = static_cast(meshopt_Allocator::storage().allocate(size)); + + if (input) + { + for (size_t i = 0; i < count; ++i) + data[i] = input[i]; + } + } + + ~meshopt_IndexAdapter() + { + if (result) + { + for (size_t i = 0; i < count; ++i) + result[i] = T(data[i]); + } + + meshopt_Allocator::storage().deallocate(data); + } +}; + +template +struct meshopt_IndexAdapter +{ + unsigned int* data; + + meshopt_IndexAdapter(T* result, const T* input, size_t) + : data(reinterpret_cast(result ? result : const_cast(input))) + { + } +}; + +template +inline size_t meshopt_generateVertexRemap(unsigned int* destination, const T* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size) +{ + meshopt_IndexAdapter in(NULL, indices, indices ? index_count : 0); + + return meshopt_generateVertexRemap(destination, indices ? in.data : NULL, index_count, vertices, vertex_count, vertex_size); +} + +template +inline size_t meshopt_generateVertexRemapMulti(unsigned int* destination, const T* indices, size_t index_count, size_t vertex_count, const meshopt_Stream* streams, size_t stream_count) +{ + meshopt_IndexAdapter in(NULL, indices, indices ? index_count : 0); + + return meshopt_generateVertexRemapMulti(destination, indices ? in.data : NULL, index_count, vertex_count, streams, stream_count); +} + +template +inline size_t meshopt_generateVertexRemapCustom(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, F callback) +{ + struct Call + { + static int compare(void* context, unsigned int lhs, unsigned int rhs) { return (*static_cast(context))(lhs, rhs) ? 1 : 0; } + }; + + return meshopt_generateVertexRemapCustom(destination, indices, index_count, vertex_positions, vertex_count, vertex_positions_stride, &Call::compare, &callback); +} + +template +inline size_t meshopt_generateVertexRemapCustom(unsigned int* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, F callback) +{ + struct Call + { + static int compare(void* context, unsigned int lhs, unsigned int rhs) { return (*static_cast(context))(lhs, rhs) ? 1 : 0; } + }; + + meshopt_IndexAdapter in(NULL, indices, indices ? index_count : 0); + + return meshopt_generateVertexRemapCustom(destination, indices ? in.data : NULL, index_count, vertex_positions, vertex_count, vertex_positions_stride, &Call::compare, &callback); +} + +template +inline void meshopt_remapIndexBuffer(T* destination, const T* indices, size_t index_count, const unsigned int* remap) +{ + meshopt_IndexAdapter in(NULL, indices, indices ? index_count : 0); + meshopt_IndexAdapter out(destination, NULL, index_count); + + meshopt_remapIndexBuffer(out.data, indices ? in.data : NULL, index_count, remap); +} + +template +inline size_t meshopt_filterIndexBuffer(T* destination, const T* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size, size_t vertex_stride) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + return meshopt_filterIndexBuffer(out.data, in.data, index_count, vertices, vertex_count, vertex_size, vertex_stride); +} + +template +inline size_t meshopt_filterIndexBufferMulti(T* destination, const T* indices, size_t index_count, size_t vertex_count, const struct meshopt_Stream* streams, size_t stream_count) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + return meshopt_filterIndexBufferMulti(out.data, in.data, index_count, vertex_count, streams, stream_count); +} + +template +inline void meshopt_generateShadowIndexBuffer(T* destination, const T* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size, size_t vertex_stride) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + meshopt_generateShadowIndexBuffer(out.data, in.data, index_count, vertices, vertex_count, vertex_size, vertex_stride); +} + +template +inline void meshopt_generateShadowIndexBufferMulti(T* destination, const T* indices, size_t index_count, size_t vertex_count, const meshopt_Stream* streams, size_t stream_count) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + meshopt_generateShadowIndexBufferMulti(out.data, in.data, index_count, vertex_count, streams, stream_count); +} + +template +inline void meshopt_generateAdjacencyIndexBuffer(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count * 2); + + meshopt_generateAdjacencyIndexBuffer(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride); +} + +template +inline void meshopt_generateTessellationIndexBuffer(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count * 4); + + meshopt_generateTessellationIndexBuffer(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride); +} + +template +inline size_t meshopt_generateProvokingIndexBuffer(T* destination, unsigned int* reorder, const T* indices, size_t index_count, size_t vertex_count) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + size_t bound = vertex_count + (index_count / 3); + assert(bound == 0 || size_t(T(bound - 1)) == bound - 1); // bound - 1 must fit in T + (void)bound; + + return meshopt_generateProvokingIndexBuffer(out.data, reorder, in.data, index_count, vertex_count); +} + +template +inline void meshopt_optimizeVertexCache(T* destination, const T* indices, size_t index_count, size_t vertex_count) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + meshopt_optimizeVertexCache(out.data, in.data, index_count, vertex_count); +} + +template +inline void meshopt_optimizeVertexCacheStrip(T* destination, const T* indices, size_t index_count, size_t vertex_count) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + meshopt_optimizeVertexCacheStrip(out.data, in.data, index_count, vertex_count); +} + +template +inline void meshopt_optimizeVertexCacheFifo(T* destination, const T* indices, size_t index_count, size_t vertex_count, unsigned int cache_size) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + meshopt_optimizeVertexCacheFifo(out.data, in.data, index_count, vertex_count, cache_size); +} + +template +inline void meshopt_optimizeOverdraw(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float threshold) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + meshopt_optimizeOverdraw(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, threshold); +} + +template +inline size_t meshopt_optimizeVertexFetchRemap(unsigned int* destination, const T* indices, size_t index_count, size_t vertex_count) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_optimizeVertexFetchRemap(destination, in.data, index_count, vertex_count); +} + +template +inline size_t meshopt_optimizeVertexFetch(void* destination, T* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size) +{ + meshopt_IndexAdapter inout(indices, indices, index_count); + + return meshopt_optimizeVertexFetch(destination, inout.data, index_count, vertices, vertex_count, vertex_size); +} + +template +inline size_t meshopt_encodeIndexBuffer(unsigned char* buffer, size_t buffer_size, const T* indices, size_t index_count) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_encodeIndexBuffer(buffer, buffer_size, in.data, index_count); +} + +template +inline int meshopt_decodeIndexBuffer(T* destination, size_t index_count, const unsigned char* buffer, size_t buffer_size) +{ + char index_size_valid[sizeof(T) == 2 || sizeof(T) == 4 ? 1 : -1]; + (void)index_size_valid; + + return meshopt_decodeIndexBuffer(destination, index_count, sizeof(T), buffer, buffer_size); +} + +template +inline size_t meshopt_encodeIndexSequence(unsigned char* buffer, size_t buffer_size, const T* indices, size_t index_count) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_encodeIndexSequence(buffer, buffer_size, in.data, index_count); +} + +template +inline int meshopt_decodeIndexSequence(T* destination, size_t index_count, const unsigned char* buffer, size_t buffer_size) +{ + char index_size_valid[sizeof(T) == 2 || sizeof(T) == 4 ? 1 : -1]; + (void)index_size_valid; + + return meshopt_decodeIndexSequence(destination, index_count, sizeof(T), buffer, buffer_size); +} + +template +inline int meshopt_decodeMeshlet(V* vertices, size_t vertex_count, T* triangles, size_t triangle_count, const unsigned char* buffer, size_t buffer_size) +{ + char types_valid[(sizeof(V) == 2 || sizeof(V) == 4) && (sizeof(T) == 1 || sizeof(T) == 4) ? 1 : -1]; + (void)types_valid; + + return meshopt_decodeMeshlet(vertices, vertex_count, sizeof(V), triangles, triangle_count, sizeof(T) == 1 ? 3 : 4, buffer, buffer_size); +} + +inline size_t meshopt_encodeVertexBufferLevel(unsigned char* buffer, size_t buffer_size, const void* vertices, size_t vertex_count, size_t vertex_size, int level) +{ + return meshopt_encodeVertexBufferLevel(buffer, buffer_size, vertices, vertex_count, vertex_size, level, -1); +} + +template +inline size_t meshopt_simplify(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t target_index_count, float target_error, unsigned int options, float* result_error) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + return meshopt_simplify(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, target_index_count, target_error, options, result_error); +} + +template +inline size_t meshopt_simplifyWithAttributes(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_attributes, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options, float* result_error) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + return meshopt_simplifyWithAttributes(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, vertex_attributes, vertex_attributes_stride, attribute_weights, attribute_count, vertex_lock, target_index_count, target_error, options, result_error); +} + +template +inline size_t meshopt_simplifyWithUpdate(T* indices, size_t index_count, float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float* vertex_attributes, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options, float* result_error) +{ + meshopt_IndexAdapter inout(indices, indices, index_count); + + return meshopt_simplifyWithUpdate(inout.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, vertex_attributes, vertex_attributes_stride, attribute_weights, attribute_count, vertex_lock, target_index_count, target_error, options, result_error); +} + +template +inline size_t meshopt_simplifySloppy(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t target_index_count, float target_error, float* result_error) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + return meshopt_simplifySloppy(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, NULL, target_index_count, target_error, result_error); +} + +template +inline size_t meshopt_simplifySloppy(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const unsigned char* vertex_lock, size_t target_index_count, float target_error, float* result_error) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + return meshopt_simplifySloppy(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, vertex_lock, target_index_count, target_error, result_error); +} + +template +inline size_t meshopt_simplifyPrune(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float target_error) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + return meshopt_simplifyPrune(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, target_error); +} + +template +inline size_t meshopt_stripify(T* destination, const T* indices, size_t index_count, size_t vertex_count, T restart_index) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, (index_count / 3) * 5); + + return meshopt_stripify(out.data, in.data, index_count, vertex_count, unsigned(restart_index)); +} + +template +inline size_t meshopt_unstripify(T* destination, const T* indices, size_t index_count, T restart_index) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count == 0 ? 0 : (index_count - 2) * 3); + + return meshopt_unstripify(out.data, in.data, index_count, unsigned(restart_index)); +} + +template +inline meshopt_VertexCacheStatistics meshopt_analyzeVertexCache(const T* indices, size_t index_count, size_t vertex_count, unsigned int cache_size, unsigned int warp_size, unsigned int primgroup_size) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_analyzeVertexCache(in.data, index_count, vertex_count, cache_size, warp_size, primgroup_size); +} + +template +inline meshopt_VertexFetchStatistics meshopt_analyzeVertexFetch(const T* indices, size_t index_count, size_t vertex_count, size_t vertex_size) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_analyzeVertexFetch(in.data, index_count, vertex_count, vertex_size); +} + +template +inline meshopt_OverdrawStatistics meshopt_analyzeOverdraw(const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_analyzeOverdraw(in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride); +} + +template +inline meshopt_CoverageStatistics meshopt_analyzeCoverage(const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_analyzeCoverage(in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride); +} + +template +inline size_t meshopt_buildMeshlets(meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t max_triangles, float cone_weight) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_buildMeshlets(meshlets, meshlet_vertices, meshlet_triangles, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, max_vertices, max_triangles, cone_weight); +} + +template +inline size_t meshopt_buildMeshletsScan(meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const T* indices, size_t index_count, size_t vertex_count, size_t max_vertices, size_t max_triangles) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_buildMeshletsScan(meshlets, meshlet_vertices, meshlet_triangles, in.data, index_count, vertex_count, max_vertices, max_triangles); +} + +template +inline size_t meshopt_buildMeshletsFlex(meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t min_triangles, size_t max_triangles, float cone_weight, float split_factor) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_buildMeshletsFlex(meshlets, meshlet_vertices, meshlet_triangles, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, max_vertices, min_triangles, max_triangles, cone_weight, split_factor); +} + +template +inline size_t meshopt_buildMeshletsSpatial(meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t min_triangles, size_t max_triangles, float fill_weight) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_buildMeshletsSpatial(meshlets, meshlet_vertices, meshlet_triangles, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, max_vertices, min_triangles, max_triangles, fill_weight); +} + +template +inline meshopt_Bounds meshopt_computeClusterBounds(const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_computeClusterBounds(in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride); +} + +template +inline size_t meshopt_partitionClusters(unsigned int* destination, const T* cluster_indices, size_t total_index_count, const unsigned int* cluster_index_counts, size_t cluster_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t target_partition_size) +{ + meshopt_IndexAdapter in(NULL, cluster_indices, total_index_count); + + return meshopt_partitionClusters(destination, in.data, total_index_count, cluster_index_counts, cluster_count, vertex_positions, vertex_count, vertex_positions_stride, target_partition_size); +} + +template +inline void meshopt_spatialSortTriangles(T* destination, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + meshopt_IndexAdapter out(destination, NULL, index_count); + + meshopt_spatialSortTriangles(out.data, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride); +} + +template +inline void meshopt_generateTangents(float* result, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_normals, size_t vertex_normals_stride, const float* vertex_uvs, size_t vertex_uvs_stride, unsigned int options) +{ + meshopt_IndexAdapter in(NULL, indices, indices ? index_count : 0); + + meshopt_generateTangents(result, indices ? in.data : NULL, index_count, vertex_positions, vertex_count, vertex_positions_stride, vertex_normals, vertex_normals_stride, vertex_uvs, vertex_uvs_stride, options); +} + +template +inline void meshopt_generateNormals(float* result, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, float crease_angle, float smoothing) +{ + meshopt_IndexAdapter in(NULL, indices, indices ? index_count : 0); + + meshopt_generateNormals(result, indices ? in.data : NULL, index_count, vertex_positions, vertex_count, vertex_positions_stride, crease_angle, smoothing); +} + +template +inline size_t meshopt_remesh(float* destination, size_t max_triangle_count, const T* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, int resolution, unsigned int options) +{ + meshopt_IndexAdapter in(NULL, indices, index_count); + + return meshopt_remesh(destination, max_triangle_count, in.data, index_count, vertex_positions, vertex_count, vertex_positions_stride, resolution, options); +} +#endif + +/** + * Copyright (c) 2016-2026 Arseny Kapoulkine + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ diff --git a/Sources/CMeshOptimizer/indexgenerator.cpp b/Sources/CMeshOptimizer/indexgenerator.cpp new file mode 100644 index 000000000..0f2961400 --- /dev/null +++ b/Sources/CMeshOptimizer/indexgenerator.cpp @@ -0,0 +1,825 @@ +// This file is part of meshoptimizer library; see meshoptimizer.h for version/license details +#include "meshoptimizer.h" + +#include +#include + +// This work is based on: +// Matthias Teschner, Bruno Heidelberger, Matthias Mueller, Danat Pomeranets, Markus Gross. Optimized Spatial Hashing for Collision Detection of Deformable Objects. 2003 +// John McDonald, Mark Kilgard. Crack-Free Point-Normal Triangles using Adjacent Edge Normals. 2010 +// John Hable. Variable Rate Shading with Visibility Buffer Rendering. 2024 +namespace meshopt +{ + +static unsigned int hashUpdate(unsigned int h, const unsigned char* key, size_t len) +{ + // MurmurHash2 + const unsigned int m = 0x5bd1e995; + const int r = 24; + + while (len >= 4) + { + unsigned int k; + memcpy(&k, key, sizeof(k)); + + k *= m; + k ^= k >> r; + k *= m; + + h *= m; + h ^= k; + + key += 4; + len -= 4; + } + + if (len > 0) + { + h ^= key[0] | (key[len / 2] << 8) | (key[len - 1] << 16); + h *= m; + h ^= h >> r; + } + + return h; +} + +struct VertexHasher +{ + const unsigned char* vertices; + size_t vertex_size; + size_t vertex_stride; + + size_t hash(unsigned int index) const + { + return hashUpdate(0, vertices + index * vertex_stride, vertex_size); + } + + bool equal(unsigned int lhs, unsigned int rhs) const + { + return memcmp(vertices + lhs * vertex_stride, vertices + rhs * vertex_stride, vertex_size) == 0; + } +}; + +struct VertexStreamHasher +{ + const meshopt_Stream* streams; + size_t stream_count; + + size_t hash(unsigned int index) const + { + unsigned int h = 0; + + for (size_t i = 0; i < stream_count; ++i) + { + const meshopt_Stream& s = streams[i]; + const unsigned char* data = static_cast(s.data); + + h = hashUpdate(h, data + index * s.stride, s.size); + } + + return h; + } + + bool equal(unsigned int lhs, unsigned int rhs) const + { + for (size_t i = 0; i < stream_count; ++i) + { + const meshopt_Stream& s = streams[i]; + const unsigned char* data = static_cast(s.data); + + if (memcmp(data + lhs * s.stride, data + rhs * s.stride, s.size) != 0) + return false; + } + + return true; + } +}; + +struct VertexCustomHasher +{ + const float* vertex_positions; + size_t vertex_stride_float; + + int (*callback)(void*, unsigned int, unsigned int); + void* context; + + size_t hash(unsigned int index) const + { + const unsigned int* key = reinterpret_cast(vertex_positions + index * vertex_stride_float); + + unsigned int x = key[0], y = key[1], z = key[2]; + + // replace negative zero with zero + x = (x == 0x80000000) ? 0 : x; + y = (y == 0x80000000) ? 0 : y; + z = (z == 0x80000000) ? 0 : z; + + // scramble bits to make sure that integer coordinates have entropy in lower bits + x ^= x >> 17; + y ^= y >> 17; + z ^= z >> 17; + + // Optimized Spatial Hashing for Collision Detection of Deformable Objects + return (x * 73856093) ^ (y * 19349663) ^ (z * 83492791); + } + + bool equal(unsigned int lhs, unsigned int rhs) const + { + const float* lp = vertex_positions + lhs * vertex_stride_float; + const float* rp = vertex_positions + rhs * vertex_stride_float; + + if (lp[0] != rp[0] || lp[1] != rp[1] || lp[2] != rp[2]) + return false; + + return callback ? callback(context, lhs, rhs) : true; + } +}; + +struct TriangleKey +{ + unsigned int a, b, c; + + bool operator==(const TriangleKey& other) const + { + return a == other.a && b == other.b && c == other.c; + } +}; + +struct TriangleKeyHasher +{ + size_t hash(const TriangleKey& k) const + { + // Optimized Spatial Hashing for Collision Detection of Deformable Objects + return (k.a * 73856093) ^ (k.b * 19349663) ^ (k.c * 83492791); + } + + bool equal(const TriangleKey& lhs, const TriangleKey& rhs) const + { + return lhs == rhs; + } +}; + +struct EdgeHasher +{ + const unsigned int* remap; + + size_t hash(unsigned long long edge) const + { + unsigned int e0 = unsigned(edge >> 32); + unsigned int e1 = unsigned(edge); + + unsigned int h1 = remap[e0]; + unsigned int h2 = remap[e1]; + + const unsigned int m = 0x5bd1e995; + + // MurmurHash64B finalizer + h1 ^= h2 >> 18; + h1 *= m; + h2 ^= h1 >> 22; + h2 *= m; + h1 ^= h2 >> 17; + h1 *= m; + h2 ^= h1 >> 19; + h2 *= m; + + return h2; + } + + bool equal(unsigned long long lhs, unsigned long long rhs) const + { + unsigned int l0 = unsigned(lhs >> 32); + unsigned int l1 = unsigned(lhs); + + unsigned int r0 = unsigned(rhs >> 32); + unsigned int r1 = unsigned(rhs); + + return remap[l0] == remap[r0] && remap[l1] == remap[r1]; + } +}; + +static size_t hashBuckets(size_t count) +{ + size_t buckets = 1; + while (buckets < count + count / 4) + buckets *= 2; + + return buckets; +} + +template +static T* hashLookup(T* table, size_t buckets, const Hash& hash, const T& key, const T& empty) +{ + assert(buckets > 0); + assert((buckets & (buckets - 1)) == 0); + + size_t hashmod = buckets - 1; + size_t bucket = hash.hash(key) & hashmod; + + for (size_t probe = 0; probe <= hashmod; ++probe) + { + T& item = table[bucket]; + + if (item == empty) + return &item; + + if (hash.equal(item, key)) + return &item; + + // hash collision, quadratic probing + bucket = (bucket + probe + 1) & hashmod; + } + + assert(false && "Hash table is full"); // unreachable + return NULL; +} + +static void buildPositionRemap(unsigned int* remap, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, meshopt_Allocator& allocator) +{ + VertexHasher vertex_hasher = {reinterpret_cast(vertex_positions), 3 * sizeof(float), vertex_positions_stride}; + + size_t vertex_table_size = hashBuckets(vertex_count); + unsigned int* vertex_table = allocator.allocate(vertex_table_size); + memset(vertex_table, -1, vertex_table_size * sizeof(unsigned int)); + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int index = unsigned(i); + unsigned int* entry = hashLookup(vertex_table, vertex_table_size, vertex_hasher, index, ~0u); + + if (*entry == ~0u) + *entry = index; + + remap[index] = *entry; + } + + allocator.deallocate(vertex_table); +} + +template +static size_t generateVertexRemap(unsigned int* remap, const unsigned int* indices, size_t index_count, size_t vertex_count, const Hash& hash, meshopt_Allocator& allocator) +{ + memset(remap, -1, vertex_count * sizeof(unsigned int)); + + size_t table_size = hashBuckets(vertex_count); + unsigned int* table = allocator.allocate(table_size); + memset(table, -1, table_size * sizeof(unsigned int)); + + unsigned int next_vertex = 0; + + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices ? indices[i] : unsigned(i); + assert(index < vertex_count); + + if (remap[index] != ~0u) + continue; + + unsigned int* entry = hashLookup(table, table_size, hash, index, ~0u); + + if (*entry == ~0u) + { + *entry = index; + remap[index] = next_vertex++; + } + else + { + assert(remap[*entry] != ~0u); + remap[index] = remap[*entry]; + } + } + + assert(next_vertex <= vertex_count); + return next_vertex; +} + +template +static void remapVertices(void* destination, const void* vertices, size_t vertex_count, size_t vertex_size, const unsigned int* remap) +{ + size_t block_size = BlockSize == 0 ? vertex_size : BlockSize; + assert(block_size == vertex_size); + + for (size_t i = 0; i < vertex_count; ++i) + if (remap[i] != ~0u) + { + assert(remap[i] < vertex_count); + memcpy(static_cast(destination) + remap[i] * block_size, static_cast(vertices) + i * block_size, block_size); + } +} + +template +static void generateShadowBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, const Hash& hash, meshopt_Allocator& allocator) +{ + unsigned int* remap = allocator.allocate(vertex_count); + memset(remap, -1, vertex_count * sizeof(unsigned int)); + + size_t table_size = hashBuckets(vertex_count); + unsigned int* table = allocator.allocate(table_size); + memset(table, -1, table_size * sizeof(unsigned int)); + + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices[i]; + assert(index < vertex_count); + + if (remap[index] == ~0u) + { + unsigned int* entry = hashLookup(table, table_size, hash, index, ~0u); + + if (*entry == ~0u) + *entry = index; + + remap[index] = *entry; + } + + destination[i] = remap[index]; + } +} + +static size_t filterIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const unsigned int* remap, size_t vertex_count, meshopt_Allocator& allocator) +{ + (void)vertex_count; + + TriangleKeyHasher hasher = {}; + TriangleKey empty = {~0u, ~0u, ~0u}; + + size_t face_count = index_count / 3; + size_t table_size = hashBuckets(face_count); + TriangleKey* table = allocator.allocate(table_size); + memset(table, -1, table_size * sizeof(TriangleKey)); + + size_t write = 0; + + for (size_t i = 0; i < face_count; ++i) + { + unsigned int a = indices[i * 3 + 0], b = indices[i * 3 + 1], c = indices[i * 3 + 2]; + assert(a < vertex_count && b < vertex_count && c < vertex_count); + + unsigned int ra = remap[a], rb = remap[b], rc = remap[c]; + + // degenerate triangle + if (ra == rb || ra == rc || rb == rc) + continue; + + // canonicalize triangle wrt winding preserving rotation + if (rb < ra && rb < rc) + { + // abc -> bca + unsigned int t = ra; + ra = rb, rb = rc, rc = t; + } + else if (rc < ra && rc < rb) + { + // abc -> cab + unsigned int t = rc; + rc = rb, rb = ra, ra = t; + } + + TriangleKey key = {ra, rb, rc}; + TriangleKey* entry = hashLookup(table, table_size, hasher, key, empty); + + // duplicate triangle + if (entry->a != ~0u) + continue; + + *entry = key; + + destination[write + 0] = a; + destination[write + 1] = b; + destination[write + 2] = c; + write += 3; + } + + return write; +} + +} // namespace meshopt + +size_t meshopt_generateVertexRemap(unsigned int* destination, const unsigned int* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size) +{ + using namespace meshopt; + + assert(indices || index_count == vertex_count); + assert(vertex_size > 0 && vertex_size <= 256); + + meshopt_Allocator allocator; + VertexHasher hasher = {static_cast(vertices), vertex_size, vertex_size}; + + return generateVertexRemap(destination, indices, index_count, vertex_count, hasher, allocator); +} + +size_t meshopt_generateVertexRemapMulti(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, const struct meshopt_Stream* streams, size_t stream_count) +{ + using namespace meshopt; + + assert(indices || index_count == vertex_count); + assert(stream_count > 0 && stream_count <= 16); + + for (size_t i = 0; i < stream_count; ++i) + { + assert(streams[i].size > 0 && streams[i].size <= 256); + assert(streams[i].size <= streams[i].stride); + } + + meshopt_Allocator allocator; + VertexStreamHasher hasher = {streams, stream_count}; + + return generateVertexRemap(destination, indices, index_count, vertex_count, hasher, allocator); +} + +size_t meshopt_generateVertexRemapCustom(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, int (*callback)(void*, unsigned int, unsigned int), void* context) +{ + using namespace meshopt; + + assert(indices || index_count == vertex_count); + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + + meshopt_Allocator allocator; + VertexCustomHasher hasher = {vertex_positions, vertex_positions_stride / sizeof(float), callback, context}; + + return generateVertexRemap(destination, indices, index_count, vertex_count, hasher, allocator); +} + +void meshopt_remapVertexBuffer(void* destination, const void* vertices, size_t vertex_count, size_t vertex_size, const unsigned int* remap) +{ + using namespace meshopt; + + assert(vertex_size > 0 && vertex_size <= 256); + + meshopt_Allocator allocator; + + // support in-place remap + if (destination == vertices) + { + unsigned char* vertices_copy = allocator.allocate(vertex_count * vertex_size); + memcpy(vertices_copy, vertices, vertex_count * vertex_size); + vertices = vertices_copy; + } + + // specialize the loop for common vertex sizes to ensure memcpy is compiled as an inlined intrinsic + switch (vertex_size) + { + case 4: + return remapVertices<4>(destination, vertices, vertex_count, vertex_size, remap); + + case 8: + return remapVertices<8>(destination, vertices, vertex_count, vertex_size, remap); + + case 12: + return remapVertices<12>(destination, vertices, vertex_count, vertex_size, remap); + + case 16: + return remapVertices<16>(destination, vertices, vertex_count, vertex_size, remap); + + default: + return remapVertices<0>(destination, vertices, vertex_count, vertex_size, remap); + } +} + +void meshopt_remapIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const unsigned int* remap) +{ + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices ? indices[i] : unsigned(i); + assert(remap[index] != ~0u); + + destination[i] = remap[index]; + } +} + +size_t meshopt_filterIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size, size_t vertex_stride) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(vertex_size > 0 && vertex_size <= 256); + assert(vertex_size <= vertex_stride); + + meshopt_Allocator allocator; + VertexHasher hasher = {static_cast(vertices), vertex_size, vertex_stride}; + + unsigned int* remap = allocator.allocate(vertex_count); + generateVertexRemap(remap, indices, index_count, vertex_count, hasher, allocator); + + return filterIndexBuffer(destination, indices, index_count, remap, vertex_count, allocator); +} + +size_t meshopt_filterIndexBufferMulti(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, const struct meshopt_Stream* streams, size_t stream_count) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(stream_count > 0 && stream_count <= 16); + + for (size_t i = 0; i < stream_count; ++i) + { + assert(streams[i].size > 0 && streams[i].size <= 256); + assert(streams[i].size <= streams[i].stride); + } + + meshopt_Allocator allocator; + VertexStreamHasher hasher = {streams, stream_count}; + + unsigned int* remap = allocator.allocate(vertex_count); + generateVertexRemap(remap, indices, index_count, vertex_count, hasher, allocator); + + return filterIndexBuffer(destination, indices, index_count, remap, vertex_count, allocator); +} + +void meshopt_generateShadowIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size, size_t vertex_stride) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(vertex_size > 0 && vertex_size <= 256); + assert(vertex_size <= vertex_stride); + + meshopt_Allocator allocator; + VertexHasher hasher = {static_cast(vertices), vertex_size, vertex_stride}; + + generateShadowBuffer(destination, indices, index_count, vertex_count, hasher, allocator); +} + +void meshopt_generateShadowIndexBufferMulti(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, const struct meshopt_Stream* streams, size_t stream_count) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(stream_count > 0 && stream_count <= 16); + + for (size_t i = 0; i < stream_count; ++i) + { + assert(streams[i].size > 0 && streams[i].size <= 256); + assert(streams[i].size <= streams[i].stride); + } + + meshopt_Allocator allocator; + VertexStreamHasher hasher = {streams, stream_count}; + + generateShadowBuffer(destination, indices, index_count, vertex_count, hasher, allocator); +} + +void meshopt_generatePositionRemap(unsigned int* destination, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + using namespace meshopt; + + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + + meshopt_Allocator allocator; + VertexCustomHasher hasher = {vertex_positions, vertex_positions_stride / sizeof(float), NULL, NULL}; + + size_t table_size = hashBuckets(vertex_count); + unsigned int* table = allocator.allocate(table_size); + memset(table, -1, table_size * sizeof(unsigned int)); + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int* entry = hashLookup(table, table_size, hasher, unsigned(i), ~0u); + + if (*entry == ~0u) + *entry = unsigned(i); + + destination[i] = *entry; + } +} + +void meshopt_generateAdjacencyIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + + meshopt_Allocator allocator; + + static const int next[4] = {1, 2, 0, 1}; + + // build position remap: for each vertex, which other (canonical) vertex does it map to? + unsigned int* remap = allocator.allocate(vertex_count); + buildPositionRemap(remap, vertex_positions, vertex_count, vertex_positions_stride, allocator); + + // build edge set; this stores all triangle edges but we can look these up by any other wedge + EdgeHasher edge_hasher = {remap}; + + size_t edge_table_size = hashBuckets(index_count); + unsigned long long* edge_table = allocator.allocate(edge_table_size); + unsigned int* edge_vertex_table = allocator.allocate(edge_table_size); + + memset(edge_table, -1, edge_table_size * sizeof(unsigned long long)); + memset(edge_vertex_table, -1, edge_table_size * sizeof(unsigned int)); + + for (size_t i = 0; i < index_count; i += 3) + { + for (int e = 0; e < 3; ++e) + { + unsigned int i0 = indices[i + e]; + unsigned int i1 = indices[i + next[e]]; + unsigned int i2 = indices[i + next[e + 1]]; + assert(i0 < vertex_count && i1 < vertex_count && i2 < vertex_count); + + unsigned long long edge = ((unsigned long long)i0 << 32) | i1; + unsigned long long* entry = hashLookup(edge_table, edge_table_size, edge_hasher, edge, ~0ull); + + if (*entry == ~0ull) + { + *entry = edge; + + // store vertex opposite to the edge + edge_vertex_table[entry - edge_table] = i2; + } + } + } + + // build resulting index buffer: 6 indices for each input triangle + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int patch[6]; + + for (int e = 0; e < 3; ++e) + { + unsigned int i0 = indices[i + e]; + unsigned int i1 = indices[i + next[e]]; + assert(i0 < vertex_count && i1 < vertex_count); + + // note: this refers to the opposite edge! + unsigned long long edge = ((unsigned long long)i1 << 32) | i0; + unsigned long long* oppe = hashLookup(edge_table, edge_table_size, edge_hasher, edge, ~0ull); + + patch[e * 2 + 0] = i0; + patch[e * 2 + 1] = (*oppe == ~0ull) ? i0 : edge_vertex_table[oppe - edge_table]; + } + + memcpy(destination + i * 2, patch, sizeof(patch)); + } +} + +void meshopt_generateTessellationIndexBuffer(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + + meshopt_Allocator allocator; + + static const int next[3] = {1, 2, 0}; + + // build position remap: for each vertex, which other (canonical) vertex does it map to? + unsigned int* remap = allocator.allocate(vertex_count); + buildPositionRemap(remap, vertex_positions, vertex_count, vertex_positions_stride, allocator); + + // build edge set; this stores all triangle edges but we can look these up by any other wedge + EdgeHasher edge_hasher = {remap}; + + size_t edge_table_size = hashBuckets(index_count); + unsigned long long* edge_table = allocator.allocate(edge_table_size); + memset(edge_table, -1, edge_table_size * sizeof(unsigned long long)); + + for (size_t i = 0; i < index_count; i += 3) + { + for (int e = 0; e < 3; ++e) + { + unsigned int i0 = indices[i + e]; + unsigned int i1 = indices[i + next[e]]; + assert(i0 < vertex_count && i1 < vertex_count); + + unsigned long long edge = ((unsigned long long)i0 << 32) | i1; + unsigned long long* entry = hashLookup(edge_table, edge_table_size, edge_hasher, edge, ~0ull); + + if (*entry == ~0ull) + *entry = edge; + } + } + + // build resulting index buffer: 12 indices for each input triangle + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int patch[12]; + + for (int e = 0; e < 3; ++e) + { + unsigned int i0 = indices[i + e]; + unsigned int i1 = indices[i + next[e]]; + assert(i0 < vertex_count && i1 < vertex_count); + + // note: this refers to the opposite edge! + unsigned long long edge = ((unsigned long long)i1 << 32) | i0; + unsigned long long oppe = *hashLookup(edge_table, edge_table_size, edge_hasher, edge, ~0ull); + + // use the same edge if opposite edge doesn't exist (border) + oppe = (oppe == ~0ull) ? edge : oppe; + + // triangle index (0, 1, 2) + patch[e] = i0; + + // opposite edge (3, 4; 5, 6; 7, 8) + patch[3 + e * 2 + 0] = unsigned(oppe); + patch[3 + e * 2 + 1] = unsigned(oppe >> 32); + + // dominant vertex (9, 10, 11) + patch[9 + e] = remap[i0]; + } + + memcpy(destination + i * 4, patch, sizeof(patch)); + } +} + +size_t meshopt_generateProvokingIndexBuffer(unsigned int* destination, unsigned int* reorder, const unsigned int* indices, size_t index_count, size_t vertex_count) +{ + assert(index_count % 3 == 0); + + meshopt_Allocator allocator; + + unsigned int* remap = allocator.allocate(vertex_count); + memset(remap, -1, vertex_count * sizeof(unsigned int)); + + // compute vertex valence; this is used to prioritize least used corner + // note: we use 8-bit counters for performance; for outlier vertices the valence is incorrect but that just affects the heuristic + unsigned char* valence = allocator.allocate(vertex_count); + memset(valence, 0, vertex_count); + + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices[i]; + assert(index < vertex_count); + + valence[index]++; + } + + unsigned int reorder_offset = 0; + + // assign provoking vertices; leave the rest for the next pass + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int a = indices[i + 0], b = indices[i + 1], c = indices[i + 2]; + assert(a < vertex_count && b < vertex_count && c < vertex_count); + + // try to rotate triangle such that provoking vertex hasn't been seen before + // if multiple vertices are new, prioritize the one with least valence + // this reduces the risk that a future triangle will have all three vertices seen + unsigned int va = remap[a] == ~0u ? valence[a] : ~0u; + unsigned int vb = remap[b] == ~0u ? valence[b] : ~0u; + unsigned int vc = remap[c] == ~0u ? valence[c] : ~0u; + + if (vb != ~0u && vb <= va && vb <= vc) + { + // abc -> bca + unsigned int t = a; + a = b, b = c, c = t; + } + else if (vc != ~0u && vc <= va && vc <= vb) + { + // abc -> cab + unsigned int t = c; + c = b, b = a, a = t; + } + + unsigned int newidx = reorder_offset; + + // now remap[a] = ~0u or all three vertices are old + // recording remap[a] makes it possible to remap future references to the same index, conserving space + if (remap[a] == ~0u) + remap[a] = newidx; + + // we need to clone the provoking vertex to get a unique index + // if all three are used the choice is arbitrary since no future triangle will be able to reuse any of these + reorder[reorder_offset++] = a; + + // note: first vertex is final, the other two will be fixed up in next pass + destination[i + 0] = newidx; + destination[i + 1] = b; + destination[i + 2] = c; + + // update vertex valences for corner heuristic + valence[a]--; + valence[b]--; + valence[c]--; + } + + // remap or clone non-provoking vertices (iterating to skip provoking vertices) + int step = 1; + + for (size_t i = 1; i < index_count; i += step, step ^= 3) + { + unsigned int index = destination[i]; + + if (remap[index] == ~0u) + { + // we haven't seen the vertex before as a provoking vertex + // to maintain the reference to the original vertex we need to clone it + unsigned int newidx = reorder_offset; + + remap[index] = newidx; + reorder[reorder_offset++] = index; + } + + destination[i] = remap[index]; + } + + assert(reorder_offset <= vertex_count + index_count / 3); + return reorder_offset; +} diff --git a/Sources/CMeshOptimizer/simplifier.cpp b/Sources/CMeshOptimizer/simplifier.cpp new file mode 100644 index 000000000..85c502524 --- /dev/null +++ b/Sources/CMeshOptimizer/simplifier.cpp @@ -0,0 +1,2947 @@ +// This file is part of meshoptimizer library; see meshoptimizer.h for version/license details +#include "meshoptimizer.h" + +#include +#include +#include +#include + +#ifndef TRACE +#define TRACE 0 +#endif + +#if TRACE +#include +#endif + +#if TRACE +#define TRACESTATS(i) stats[i]++; +#else +#define TRACESTATS(i) (void)0 +#endif + +// This work is based on: +// Michael Garland and Paul S. Heckbert. Surface simplification using quadric error metrics. 1997 +// Michael Garland. Quadric-based polygonal surface simplification. 1999 +// Peter Lindstrom. Out-of-Core Simplification of Large Polygonal Models. 2000 +// Matthias Teschner, Bruno Heidelberger, Matthias Mueller, Danat Pomeranets, Markus Gross. Optimized Spatial Hashing for Collision Detection of Deformable Objects. 2003 +// Peter Van Sandt, Yannis Chronis, Jignesh M. Patel. Efficiently Searching In-Memory Sorted Arrays: Revenge of the Interpolation Search? 2019 +// Hugues Hoppe. New Quadric Metric for Simplifying Meshes with Appearance Attributes. 1999 +// Hugues Hoppe, Steve Marschner. Efficient Minimization of New Quadric Metric for Simplifying Meshes with Appearance Attributes. 2000 +namespace meshopt +{ + +struct EdgeAdjacency +{ + struct Edge + { + unsigned int next; + unsigned int prev; + }; + + unsigned int* offsets; + Edge* data; +}; + +static void prepareEdgeAdjacency(EdgeAdjacency& adjacency, size_t index_count, size_t vertex_count, meshopt_Allocator& allocator) +{ + adjacency.offsets = allocator.allocate(vertex_count + 1); + adjacency.data = allocator.allocate(index_count); +} + +static void updateEdgeAdjacency(EdgeAdjacency& adjacency, const unsigned int* indices, size_t index_count, size_t vertex_count, const unsigned int* remap) +{ + size_t face_count = index_count / 3; + unsigned int* offsets = adjacency.offsets + 1; + EdgeAdjacency::Edge* data = adjacency.data; + + // fill edge counts + memset(offsets, 0, vertex_count * sizeof(unsigned int)); + + for (size_t i = 0; i < index_count; ++i) + { + unsigned int v = remap ? remap[indices[i]] : indices[i]; + assert(v < vertex_count); + + offsets[v]++; + } + + // fill offset table + unsigned int offset = 0; + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int count = offsets[i]; + offsets[i] = offset; + offset += count; + } + + assert(offset == index_count); + + // fill edge data + for (size_t i = 0; i < face_count; ++i) + { + unsigned int a = indices[i * 3 + 0], b = indices[i * 3 + 1], c = indices[i * 3 + 2]; + + if (remap) + { + a = remap[a]; + b = remap[b]; + c = remap[c]; + } + + data[offsets[a]].next = b; + data[offsets[a]].prev = c; + offsets[a]++; + + data[offsets[b]].next = c; + data[offsets[b]].prev = a; + offsets[b]++; + + data[offsets[c]].next = a; + data[offsets[c]].prev = b; + offsets[c]++; + } + + // finalize offsets + adjacency.offsets[0] = 0; + assert(adjacency.offsets[vertex_count] == index_count); +} + +struct PositionHasher +{ + const float* vertex_positions; + size_t vertex_stride_float; + const unsigned int* sparse_remap; + + size_t hash(unsigned int index) const + { + unsigned int ri = sparse_remap ? sparse_remap[index] : index; + const unsigned int* key = reinterpret_cast(vertex_positions + ri * vertex_stride_float); + + unsigned int x = key[0], y = key[1], z = key[2]; + + // replace negative zero with zero + x = (x == 0x80000000) ? 0 : x; + y = (y == 0x80000000) ? 0 : y; + z = (z == 0x80000000) ? 0 : z; + + // scramble bits to make sure that integer coordinates have entropy in lower bits + x ^= x >> 17; + y ^= y >> 17; + z ^= z >> 17; + + // Optimized Spatial Hashing for Collision Detection of Deformable Objects + return (x * 73856093) ^ (y * 19349663) ^ (z * 83492791); + } + + bool equal(unsigned int lhs, unsigned int rhs) const + { + unsigned int li = sparse_remap ? sparse_remap[lhs] : lhs; + unsigned int ri = sparse_remap ? sparse_remap[rhs] : rhs; + + const float* lv = vertex_positions + li * vertex_stride_float; + const float* rv = vertex_positions + ri * vertex_stride_float; + + return lv[0] == rv[0] && lv[1] == rv[1] && lv[2] == rv[2]; + } +}; + +struct RemapHasher +{ + unsigned int* remap; + + size_t hash(unsigned int id) const + { + return id * 0x5bd1e995; + } + + bool equal(unsigned int lhs, unsigned int rhs) const + { + return remap[lhs] == rhs; + } +}; + +static size_t hashBuckets2(size_t count) +{ + size_t buckets = 1; + while (buckets < count + count / 4) + buckets *= 2; + + return buckets; +} + +template +static T* hashLookup2(T* table, size_t buckets, const Hash& hash, const T& key, const T& empty) +{ + assert(buckets > 0); + assert((buckets & (buckets - 1)) == 0); + + size_t hashmod = buckets - 1; + size_t bucket = hash.hash(key) & hashmod; + + for (size_t probe = 0; probe <= hashmod; ++probe) + { + T& item = table[bucket]; + + if (item == empty) + return &item; + + if (hash.equal(item, key)) + return &item; + + // hash collision, quadratic probing + bucket = (bucket + probe + 1) & hashmod; + } + + assert(false && "Hash table is full"); // unreachable + return NULL; +} + +static void buildPositionRemap(unsigned int* remap, unsigned int* wedge, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, const unsigned int* sparse_remap, meshopt_Allocator& allocator) +{ + PositionHasher hasher = {vertex_positions_data, vertex_positions_stride / sizeof(float), sparse_remap}; + + size_t table_size = hashBuckets2(vertex_count); + unsigned int* table = allocator.allocate(table_size); + memset(table, -1, table_size * sizeof(unsigned int)); + + // build forward remap: for each vertex, which other (canonical) vertex does it map to? + // we use position equivalence for this, and remap vertices to other existing vertices + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int index = unsigned(i); + unsigned int* entry = hashLookup2(table, table_size, hasher, index, ~0u); + + if (*entry == ~0u) + *entry = index; + + remap[index] = *entry; + } + + allocator.deallocate(table); + + if (!wedge) + return; + + // build wedge table: for each vertex, which other vertex is the next wedge that also maps to the same vertex? + // entries in table form a (cyclic) wedge loop per vertex; for manifold vertices, wedge[i] == remap[i] == i + for (size_t i = 0; i < vertex_count; ++i) + wedge[i] = unsigned(i); + + for (size_t i = 0; i < vertex_count; ++i) + if (remap[i] != i) + { + unsigned int r = remap[i]; + + wedge[i] = wedge[r]; + wedge[r] = unsigned(i); + } +} + +static unsigned int* buildSparseRemap(unsigned int* indices, size_t index_count, size_t vertex_count, size_t* out_vertex_count, meshopt_Allocator& allocator) +{ + // use a bit set to compute the precise number of unique vertices + unsigned char* filter = allocator.allocate((vertex_count + 7) / 8); + + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices[i]; + assert(index < vertex_count); + filter[index / 8] = 0; + } + + size_t unique = 0; + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices[i]; + unique += (filter[index / 8] & (1 << (index % 8))) == 0; + filter[index / 8] |= 1 << (index % 8); + } + + unsigned int* remap = allocator.allocate(unique); + size_t offset = 0; + + // temporary map dense => sparse; we allocate it last so that we can deallocate it + size_t revremap_size = hashBuckets2(unique); + unsigned int* revremap = allocator.allocate(revremap_size); + memset(revremap, -1, revremap_size * sizeof(unsigned int)); + + // fill remap, using revremap as a helper, and rewrite indices in the same pass + RemapHasher hasher = {remap}; + + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices[i]; + unsigned int* entry = hashLookup2(revremap, revremap_size, hasher, index, ~0u); + + if (*entry == ~0u) + { + remap[offset] = index; + *entry = unsigned(offset); + offset++; + } + + indices[i] = *entry; + } + + allocator.deallocate(revremap); + + assert(offset == unique); + *out_vertex_count = unique; + + return remap; +} + +enum VertexKind +{ + Kind_Manifold, // not on an attribute seam, not on any boundary + Kind_Border, // not on an attribute seam, has exactly two open edges + Kind_Seam, // on an attribute seam with exactly two attribute seam edges + Kind_Complex, // none of the above; these vertices can move as long as all wedges move to the target vertex + Kind_Fringe, // complex vertex on a geometric border; can only move to another fringe vertex + Kind_Locked, // none of the above; these vertices can't move + + Kind_Count +}; + +// manifold vertices can collapse onto anything +// border/seam vertices can collapse onto border/seam respectively, or locked +// complex vertices can collapse onto complex/locked +// fringe vertices can only collapse onto fringe vertices; border, manifold and complex vertices can collapse onto them +// a rule of thumb is that collapsing kind A into kind B preserves the kind B in the target vertex +// for example, while we could collapse Complex into Manifold, this would mean the target vertex isn't Manifold anymore +const unsigned char kCanCollapse[Kind_Count][Kind_Count] = { + {1, 1, 1, 1, 1, 1}, + {0, 1, 0, 0, 1, 1}, + {0, 0, 1, 0, 0, 1}, + {0, 0, 0, 1, 1, 1}, + {0, 0, 0, 0, 1, 0}, + {0, 0, 0, 0, 0, 0}, +}; + +// if a vertex is manifold or seam, adjoining edges are guaranteed to have an opposite edge +// note that for seam edges, the opposite edge isn't present in the attribute-based topology +// but is present if you consider a position-only mesh variant +// while many complex collapses have the opposite edge, since complex vertices collapse to the +// same wedge, keeping opposite edges separate improves the quality by considering both targets +const unsigned char kHasOpposite[Kind_Count][Kind_Count] = { + {1, 1, 1, 1, 1, 1}, + {1, 0, 1, 0, 0, 0}, + {1, 1, 1, 0, 0, 1}, + {1, 0, 0, 0, 0, 0}, + {1, 0, 0, 0, 0, 0}, + {1, 0, 1, 0, 0, 0}, +}; + +static bool hasEdge(const EdgeAdjacency& adjacency, unsigned int a, unsigned int b) +{ + unsigned int count = adjacency.offsets[a + 1] - adjacency.offsets[a]; + const EdgeAdjacency::Edge* edges = adjacency.data + adjacency.offsets[a]; + + for (size_t i = 0; i < count; ++i) + if (edges[i].next == b) + return true; + + return false; +} + +static bool hasEdge(const EdgeAdjacency& adjacency, unsigned int a, unsigned int b, const unsigned int* remap, const unsigned int* wedge) +{ + unsigned int v = a; + + do + { + unsigned int count = adjacency.offsets[v + 1] - adjacency.offsets[v]; + const EdgeAdjacency::Edge* edges = adjacency.data + adjacency.offsets[v]; + + for (size_t i = 0; i < count; ++i) + if (remap[edges[i].next] == remap[b]) + return true; + + v = wedge[v]; + } while (v != a); + + return false; +} + +static void classifyVertices(unsigned char* result, unsigned int* loop, unsigned int* loopback, size_t vertex_count, const EdgeAdjacency& adjacency, const unsigned int* remap, const unsigned int* wedge, const unsigned char* vertex_lock, const unsigned int* sparse_remap, unsigned int options) +{ + memset(loop, -1, vertex_count * sizeof(unsigned int)); + memset(loopback, -1, vertex_count * sizeof(unsigned int)); + + // incoming & outgoing open edges: ~0u if no open edges, i if there are more than 1 + // note that this is the same data as required in loop[] arrays; loop[] data is only used for border/seam by default + // in permissive mode we also use it to guide complex-complex collapses, so we fill it for all vertices + unsigned int* openinc = loopback; + unsigned int* openout = loop; + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int vertex = unsigned(i); + + unsigned int count = adjacency.offsets[vertex + 1] - adjacency.offsets[vertex]; + const EdgeAdjacency::Edge* edges = adjacency.data + adjacency.offsets[vertex]; + + for (size_t j = 0; j < count; ++j) + { + unsigned int target = edges[j].next; + + if (target == vertex) + { + // degenerate triangles have two distinct edges instead of three, and the self edge + // is bi-directional by definition; this can break border/seam classification by "closing" + // the open edge from another triangle and falsely marking the vertex as manifold + // instead we mark the vertex as having >1 open edges which turns it into locked/complex + openinc[vertex] = openout[vertex] = vertex; + } + else if (!hasEdge(adjacency, target, vertex)) + { + openinc[target] = (openinc[target] == ~0u) ? vertex : target; + openout[vertex] = (openout[vertex] == ~0u) ? target : vertex; + } + } + } + + for (size_t i = 0; i < vertex_count; ++i) + { + if (remap[i] == i) + { + if (wedge[i] == i) + { + // no attribute seam, need to check if it's manifold + unsigned int openi = openinc[i], openo = openout[i]; + + // note: we classify any vertices with no open edges as manifold + // this is technically incorrect - if 4 triangles share an edge, we'll classify vertices as manifold + // it's unclear if this is a problem in practice + if (openi == ~0u && openo == ~0u) + { + result[i] = Kind_Manifold; + } + else if (openi != ~0u && openo != ~0u && remap[openi] == remap[openo] && openi != i) + { + // classify half-seams as seams (the branch below would mis-classify them as borders) + // half-seam is a single vertex that connects to both vertices of a potential seam + // treating these as seams allows collapsing the "full" seam vertex onto them + result[i] = Kind_Seam; + } + else if (openi != i && openo != i) + { + result[i] = Kind_Border; + } + else + { + result[i] = Kind_Locked; + } + } + else if (wedge[wedge[i]] == i) + { + // attribute seam; need to distinguish between Seam and Locked + unsigned int w = wedge[i]; + unsigned int openiv = openinc[i], openov = openout[i]; + unsigned int openiw = openinc[w], openow = openout[w]; + + // seam should have one open half-edge for each vertex, and the edges need to "connect" - point to the same vertex post-remap + if (openiv != ~0u && openiv != i && openov != ~0u && openov != i && + openiw != ~0u && openiw != w && openow != ~0u && openow != w && + remap[openiv] == remap[openow] && remap[openov] == remap[openiw] && remap[openiv] != remap[openov]) + { + result[i] = Kind_Seam; + } + else + { + // mismatched seam endpoints or disconnected seam; we don't have classification available + result[i] = Kind_Locked; + } + } + else + { + // more than one vertex maps to this one; we don't have classification available + result[i] = Kind_Locked; + } + } + else + { + assert(remap[i] < i); + + result[i] = result[remap[i]]; + } + } + + if (options & meshopt_SimplifyPermissive) + for (size_t i = 0; i < vertex_count; ++i) + if (result[i] == Kind_Seam || result[i] == Kind_Locked) + { + if (remap[i] != i) + { + // only process primary vertices; wedges will be updated to match the primary vertex + result[i] = result[remap[i]]; + continue; + } + + unsigned int v = unsigned(i); + + // vertex_lock may protect any wedge, not just the primary vertex, so we switch to complex only if no wedges are protected + bool protect = false; + do + { + unsigned int rv = sparse_remap ? sparse_remap[v] : v; + protect |= vertex_lock && (vertex_lock[rv] & meshopt_SimplifyVertex_Protect) != 0; + v = wedge[v]; + } while (v != i); + + // check if any adjoining edge doesn't have an opposite edge (indicating vertex is on the border) + bool border = false; + do + { + const EdgeAdjacency::Edge* edges = &adjacency.data[adjacency.offsets[v]]; + size_t count = adjacency.offsets[v + 1] - adjacency.offsets[v]; + + for (size_t j = 0; j < count; ++j) + border |= !hasEdge(adjacency, edges[j].next, v, remap, wedge); + v = wedge[v]; + } while (v != i); + + // border vertices with attribute discontinuities can only collapse along the border, so they need a separate tagging + result[i] = protect ? result[i] : (unsigned char)(border ? Kind_Fringe : Kind_Complex); + } + + if (vertex_lock) + { + // vertex_lock may lock any wedge, not just the primary vertex, so we need to lock the primary vertex and relock any wedges + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int ri = sparse_remap ? sparse_remap[i] : unsigned(i); + + if (vertex_lock[ri] & meshopt_SimplifyVertex_Lock) + result[remap[i]] = Kind_Locked; + } + + for (size_t i = 0; i < vertex_count; ++i) + if (result[remap[i]] == Kind_Locked) + result[i] = Kind_Locked; + } + + if (options & meshopt_SimplifyLockBorder) + for (size_t i = 0; i < vertex_count; ++i) + if (result[i] == Kind_Border || result[i] == Kind_Fringe) + result[i] = Kind_Locked; +} + +struct Vector3 +{ + float x, y, z; +}; + +static float rescalePositions(Vector3* result, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, const unsigned int* sparse_remap = NULL, float* out_offset = NULL) +{ + size_t vertex_stride_float = vertex_positions_stride / sizeof(float); + + float minv[3] = {FLT_MAX, FLT_MAX, FLT_MAX}; + float maxv[3] = {-FLT_MAX, -FLT_MAX, -FLT_MAX}; + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int ri = sparse_remap ? sparse_remap[i] : unsigned(i); + const float* v = vertex_positions_data + ri * vertex_stride_float; + + if (result) + { + result[i].x = v[0]; + result[i].y = v[1]; + result[i].z = v[2]; + } + + for (int j = 0; j < 3; ++j) + { + float vj = v[j]; + + minv[j] = minv[j] > vj ? vj : minv[j]; + maxv[j] = maxv[j] < vj ? vj : maxv[j]; + } + } + + float extent = 0.f; + + extent = (maxv[0] - minv[0]) < extent ? extent : (maxv[0] - minv[0]); + extent = (maxv[1] - minv[1]) < extent ? extent : (maxv[1] - minv[1]); + extent = (maxv[2] - minv[2]) < extent ? extent : (maxv[2] - minv[2]); + + if (result) + { + float scale = extent == 0 ? 0.f : 1.f / extent; + + for (size_t i = 0; i < vertex_count; ++i) + { + result[i].x = (result[i].x - minv[0]) * scale; + result[i].y = (result[i].y - minv[1]) * scale; + result[i].z = (result[i].z - minv[2]) * scale; + } + } + + if (out_offset) + { + out_offset[0] = minv[0]; + out_offset[1] = minv[1]; + out_offset[2] = minv[2]; + } + + return extent; +} + +static void rescaleAttributes(float* result, const float* vertex_attributes_data, size_t vertex_count, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned int* attribute_remap, const unsigned int* sparse_remap) +{ + size_t vertex_attributes_stride_float = vertex_attributes_stride / sizeof(float); + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int ri = sparse_remap ? sparse_remap[i] : unsigned(i); + + for (size_t k = 0; k < attribute_count; ++k) + { + unsigned int rk = attribute_remap[k]; + float a = vertex_attributes_data[ri * vertex_attributes_stride_float + rk]; + + result[i * attribute_count + k] = a * attribute_weights[rk]; + } + } +} + +static void finalizeVertices(float* vertex_positions_data, size_t vertex_positions_stride, float* vertex_attributes_data, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, size_t vertex_count, const Vector3* vertex_positions, const float* vertex_attributes, const unsigned int* sparse_remap, const unsigned int* attribute_remap, float vertex_scale, const float* vertex_offset, const unsigned char* vertex_kind, const unsigned char* vertex_update, const unsigned char* vertex_lock) +{ + size_t vertex_positions_stride_float = vertex_positions_stride / sizeof(float); + size_t vertex_attributes_stride_float = vertex_attributes_stride / sizeof(float); + + for (size_t i = 0; i < vertex_count; ++i) + { + if (!vertex_update[i]) + continue; + + unsigned int ri = sparse_remap ? sparse_remap[i] : unsigned(i); + + // updating externally locked vertices is not allowed + if (vertex_lock && (vertex_lock[ri] & meshopt_SimplifyVertex_Lock) != 0) + continue; + + // moving locked vertices may result in floating point drift + if (vertex_kind[i] != Kind_Locked) + { + const Vector3& p = vertex_positions[i]; + float* v = vertex_positions_data + ri * vertex_positions_stride_float; + + v[0] = p.x * vertex_scale + vertex_offset[0]; + v[1] = p.y * vertex_scale + vertex_offset[1]; + v[2] = p.z * vertex_scale + vertex_offset[2]; + } + + if (attribute_count) + { + const float* sa = vertex_attributes + i * attribute_count; + float* va = vertex_attributes_data + ri * vertex_attributes_stride_float; + + for (size_t k = 0; k < attribute_count; ++k) + { + unsigned int rk = attribute_remap[k]; + + va[rk] = sa[k] / attribute_weights[rk]; + } + } + } +} + +static const size_t kMaxAttributes = 32; + +struct Quadric +{ + // a00*x^2 + a11*y^2 + a22*z^2 + 2*a10*xy + 2*a20*xz + 2*a21*yz + 2*b0*x + 2*b1*y + 2*b2*z + c + float a00, a11, a22; + float a10, a20, a21; + float b0, b1, b2, c; + float w; +}; + +struct QuadricGrad +{ + // gx*x + gy*y + gz*z + gw + float gx, gy, gz, gw; +}; + +struct Reservoir +{ + float x, y, z; + float r, g, b; + float w; +}; + +struct Collapse +{ + unsigned int v0; + unsigned int v1; + + union + { + unsigned int bidi; + float error; + unsigned int errorui; + }; +}; + +static float normalize(Vector3& v) +{ + float length = sqrtf(v.x * v.x + v.y * v.y + v.z * v.z); + + if (length > 0) + { + v.x /= length; + v.y /= length; + v.z /= length; + } + + return length; +} + +static void quadricAdd(Quadric& Q, const Quadric& R) +{ + Q.a00 += R.a00; + Q.a11 += R.a11; + Q.a22 += R.a22; + Q.a10 += R.a10; + Q.a20 += R.a20; + Q.a21 += R.a21; + Q.b0 += R.b0; + Q.b1 += R.b1; + Q.b2 += R.b2; + Q.c += R.c; + Q.w += R.w; +} + +static void quadricAdd(QuadricGrad& G, const QuadricGrad& R) +{ + G.gx += R.gx; + G.gy += R.gy; + G.gz += R.gz; + G.gw += R.gw; +} + +static void quadricAdd(QuadricGrad* G, const QuadricGrad* R, size_t attribute_count) +{ + for (size_t k = 0; k < attribute_count; ++k) + { + G[k].gx += R[k].gx; + G[k].gy += R[k].gy; + G[k].gz += R[k].gz; + G[k].gw += R[k].gw; + } +} + +static float quadricEval(const Quadric& Q, const Vector3& v) +{ + float rx = (Q.b0 + Q.a10 * v.y) * 2.f; + float ry = (Q.b1 + Q.a21 * v.z) * 2.f; + float rz = (Q.b2 + Q.a20 * v.x) * 2.f; + + rx += Q.a00 * v.x; + ry += Q.a11 * v.y; + rz += Q.a22 * v.z; + + return Q.c + rx * v.x + ry * v.y + rz * v.z; +} + +static float quadricError(const Quadric& Q, const Vector3& v) +{ + float r = quadricEval(Q, v); + float s = Q.w == 0.f ? 0.f : 1.f / Q.w; + + return fabsf(r) * s; +} + +static float quadricError(const Quadric& Q, const QuadricGrad* G, size_t attribute_count, const Vector3& v, const float* va, bool clamped) +{ + float r = quadricEval(Q, v); + + // see quadricFromAttributes for general derivation; here we need to add the parts of (eval(pos) - attr)^2 that depend on attr + for (size_t k = 0; k < attribute_count; ++k) + { + float a = va[k]; + float g = v.x * G[k].gx + v.y * G[k].gy + v.z * G[k].gz + G[k].gw; + + r += a * (a * Q.w - 2 * g); + } + + // note: unlike position error, we do not normalize by Q.w to retain edge scaling as described in quadricFromAttributes + // we do support clamping, with the error limited to the cumulative weight (= area), which makes perceptual error map to position error more closely + float e = fabsf(r); + return clamped ? (e < Q.w ? e : Q.w) : e; +} + +static void quadricFromPlane(Quadric& Q, float a, float b, float c, float d, float w) +{ + float aw = a * w; + float bw = b * w; + float cw = c * w; + float dw = d * w; + + Q.a00 = a * aw; + Q.a11 = b * bw; + Q.a22 = c * cw; + Q.a10 = a * bw; + Q.a20 = a * cw; + Q.a21 = b * cw; + Q.b0 = a * dw; + Q.b1 = b * dw; + Q.b2 = c * dw; + Q.c = d * dw; + Q.w = w; +} + +static void quadricFromPoint(Quadric& Q, float x, float y, float z, float w) +{ + Q.a00 = Q.a11 = Q.a22 = w; + Q.a10 = Q.a20 = Q.a21 = 0; + Q.b0 = -x * w; + Q.b1 = -y * w; + Q.b2 = -z * w; + Q.c = (x * x + y * y + z * z) * w; + Q.w = w; +} + +static void quadricFromTriangle(Quadric& Q, const Vector3& p0, const Vector3& p1, const Vector3& p2, float weight) +{ + Vector3 p10 = {p1.x - p0.x, p1.y - p0.y, p1.z - p0.z}; + Vector3 p20 = {p2.x - p0.x, p2.y - p0.y, p2.z - p0.z}; + + // normal = cross(p1 - p0, p2 - p0) + Vector3 normal = {p10.y * p20.z - p10.z * p20.y, p10.z * p20.x - p10.x * p20.z, p10.x * p20.y - p10.y * p20.x}; + float area = normalize(normal); + + float distance = normal.x * p0.x + normal.y * p0.y + normal.z * p0.z; + + // we use sqrtf(area) so that the error is scaled linearly; this tends to improve silhouettes + quadricFromPlane(Q, normal.x, normal.y, normal.z, -distance, sqrtf(area) * weight); +} + +static void quadricFromTriangleEdge(Quadric& Q, const Vector3& p0, const Vector3& p1, const Vector3& p2, float weight) +{ + Vector3 p10 = {p1.x - p0.x, p1.y - p0.y, p1.z - p0.z}; + + // edge length; keep squared length around for projection correction + float lengthsq = p10.x * p10.x + p10.y * p10.y + p10.z * p10.z; + float length = sqrtf(lengthsq); + + // p20p = length of projection of p2-p0 onto p1-p0; note that p10 is unnormalized so we need to correct it later + Vector3 p20 = {p2.x - p0.x, p2.y - p0.y, p2.z - p0.z}; + float p20p = p20.x * p10.x + p20.y * p10.y + p20.z * p10.z; + + // perp = perpendicular vector from p2 to line segment p1-p0 + // note: since p10 is unnormalized we need to correct the projection; we scale p20 instead to take advantage of normalize below + Vector3 perp = {p20.x * lengthsq - p10.x * p20p, p20.y * lengthsq - p10.y * p20p, p20.z * lengthsq - p10.z * p20p}; + normalize(perp); + + float distance = perp.x * p0.x + perp.y * p0.y + perp.z * p0.z; + + // note: the weight is scaled linearly with edge length; this has to match the triangle weight + quadricFromPlane(Q, perp.x, perp.y, perp.z, -distance, length * weight); +} + +static void quadricFromAttributes(Quadric& Q, QuadricGrad* G, const Vector3& p0, const Vector3& p1, const Vector3& p2, const float* va0, const float* va1, const float* va2, size_t attribute_count) +{ + // for each attribute we want to encode the following function into the quadric: + // (eval(pos) - attr)^2 + // where eval(pos) interpolates attribute across the triangle like so: + // eval(pos) = pos.x * gx + pos.y * gy + pos.z * gz + gw + // where gx/gy/gz/gw are gradients + Vector3 p10 = {p1.x - p0.x, p1.y - p0.y, p1.z - p0.z}; + Vector3 p20 = {p2.x - p0.x, p2.y - p0.y, p2.z - p0.z}; + + // normal = cross(p1 - p0, p2 - p0) + Vector3 normal = {p10.y * p20.z - p10.z * p20.y, p10.z * p20.x - p10.x * p20.z, p10.x * p20.y - p10.y * p20.x}; + float area = sqrtf(normal.x * normal.x + normal.y * normal.y + normal.z * normal.z) * 0.5f; + + // quadric is weighted with the square of edge length (= area) + // this equalizes the units with the positional error (which, after normalization, is a square of distance) + // as a result, a change in weighted attribute of 1 along distance d is approximately equivalent to a change in position of d + float w = area; + + // we compute gradients using barycentric coordinates; barycentric coordinates can be computed as follows: + // v = (d11 * d20 - d01 * d21) / denom + // w = (d00 * d21 - d01 * d20) / denom + // u = 1 - v - w + // here v0, v1 are triangle edge vectors, v2 is a vector from point to triangle corner, and dij = dot(vi, vj) + // note: v2 and d20/d21 can not be evaluated here as v2 is effectively an unknown variable; we need these only as variables for derivation of gradients + const Vector3& v0 = p10; + const Vector3& v1 = p20; + float d00 = v0.x * v0.x + v0.y * v0.y + v0.z * v0.z; + float d01 = v0.x * v1.x + v0.y * v1.y + v0.z * v1.z; + float d11 = v1.x * v1.x + v1.y * v1.y + v1.z * v1.z; + float denom = d00 * d11 - d01 * d01; + float denomr = denom == 0 ? 0.f : 1.f / denom; + + // precompute gradient factors + // these are derived by directly computing derivative of eval(pos) = a0 * u + a1 * v + a2 * w and factoring out expressions that are shared between attributes + float gx1 = (d11 * v0.x - d01 * v1.x) * denomr; + float gx2 = (d00 * v1.x - d01 * v0.x) * denomr; + float gy1 = (d11 * v0.y - d01 * v1.y) * denomr; + float gy2 = (d00 * v1.y - d01 * v0.y) * denomr; + float gz1 = (d11 * v0.z - d01 * v1.z) * denomr; + float gz2 = (d00 * v1.z - d01 * v0.z) * denomr; + + memset(&Q, 0, sizeof(Quadric)); + + Q.w = w; + + for (size_t k = 0; k < attribute_count; ++k) + { + float a0 = va0[k], a1 = va1[k], a2 = va2[k]; + + // compute gradient of eval(pos) for x/y/z/w + // the formulas below are obtained by directly computing derivative of eval(pos) = a0 * u + a1 * v + a2 * w + float gx = gx1 * (a1 - a0) + gx2 * (a2 - a0); + float gy = gy1 * (a1 - a0) + gy2 * (a2 - a0); + float gz = gz1 * (a1 - a0) + gz2 * (a2 - a0); + float gw = a0 - p0.x * gx - p0.y * gy - p0.z * gz; + + // quadric encodes (eval(pos)-attr)^2; this means that the resulting expansion needs to compute, for example, pos.x * pos.y * K + // since quadrics already encode factors for pos.x * pos.y, we can accumulate almost everything in basic quadric fields + // note: for simplicity we scale all factors by weight here instead of outside the loop + Q.a00 += w * (gx * gx); + Q.a11 += w * (gy * gy); + Q.a22 += w * (gz * gz); + + Q.a10 += w * (gy * gx); + Q.a20 += w * (gz * gx); + Q.a21 += w * (gz * gy); + + Q.b0 += w * (gx * gw); + Q.b1 += w * (gy * gw); + Q.b2 += w * (gz * gw); + + Q.c += w * (gw * gw); + + // the only remaining sum components are ones that depend on attr; these will be added during error evaluation, see quadricError + G[k].gx = w * gx; + G[k].gy = w * gy; + G[k].gz = w * gz; + G[k].gw = w * gw; + } +} + +static void quadricVolumeGradient(QuadricGrad& G, const Vector3& p0, const Vector3& p1, const Vector3& p2) +{ + Vector3 p10 = {p1.x - p0.x, p1.y - p0.y, p1.z - p0.z}; + Vector3 p20 = {p2.x - p0.x, p2.y - p0.y, p2.z - p0.z}; + + // normal = cross(p1 - p0, p2 - p0) + Vector3 normal = {p10.y * p20.z - p10.z * p20.y, p10.z * p20.x - p10.x * p20.z, p10.x * p20.y - p10.y * p20.x}; + float area = normalize(normal) * 0.5f; + + G.gx = normal.x * area; + G.gy = normal.y * area; + G.gz = normal.z * area; + G.gw = (-p0.x * normal.x - p0.y * normal.y - p0.z * normal.z) * area; +} + +static bool quadricSolve(Vector3& p, const Quadric& Q, const QuadricGrad& GV) +{ + // solve A*p = -b where A is the quadric matrix and b is the linear term + float a00 = Q.a00, a11 = Q.a11, a22 = Q.a22; + float a10 = Q.a10, a20 = Q.a20, a21 = Q.a21; + float x0 = -Q.b0, x1 = -Q.b1, x2 = -Q.b2; + + float eps = 1e-6f * Q.w; + + // LDL decomposition: A = LDL^T + float d0 = a00; + float l10 = a10 / d0; + float l20 = a20 / d0; + + float d1 = a11 - a10 * l10; + float dl21 = a21 - a20 * l10; + float l21 = dl21 / d1; + + float d2 = a22 - a20 * l20 - dl21 * l21; + + // solve L*y = x + float y0 = x0; + float y1 = x1 - l10 * y0; + float y2 = x2 - l20 * y0 - l21 * y1; + + // solve D*z = y + float z0 = y0 / d0; + float z1 = y1 / d1; + float z2 = y2 / d2; + + // augment system with linear constraint GV using Lagrange multiplier + float a30 = GV.gx, a31 = GV.gy, a32 = GV.gz; + float x3 = -GV.gw; + + float l30 = a30 / d0; + float dl31 = a31 - a30 * l10; + float l31 = dl31 / d1; + float dl32 = a32 - a30 * l20 - dl31 * l21; + float l32 = dl32 / d2; + float d3 = 0.f - a30 * l30 - dl31 * l31 - dl32 * l32; + + float y3 = x3 - l30 * y0 - l31 * y1 - l32 * y2; + float z3 = fabsf(d3) > eps ? y3 / d3 : 0.f; // if d3 is zero, we can ignore the constraint + + // substitute L^T*p = z + float lambda = z3; + float pz = z2 - l32 * lambda; + float py = z1 - l21 * pz - l31 * lambda; + float px = z0 - l10 * py - l20 * pz - l30 * lambda; + + p.x = px; + p.y = py; + p.z = pz; + + return fabsf(d0) > eps && fabsf(d1) > eps && fabsf(d2) > eps; +} + +static void quadricReduceAttributes(Quadric& Q, const Quadric& A, const QuadricGrad* G, size_t attribute_count) +{ + // update vertex quadric with attribute quadric; multiply by vertex weight to minimize normalized error + Q.a00 += A.a00 * Q.w; + Q.a11 += A.a11 * Q.w; + Q.a22 += A.a22 * Q.w; + Q.a10 += A.a10 * Q.w; + Q.a20 += A.a20 * Q.w; + Q.a21 += A.a21 * Q.w; + Q.b0 += A.b0 * Q.w; + Q.b1 += A.b1 * Q.w; + Q.b2 += A.b2 * Q.w; + + float iaw = A.w == 0 ? 0.f : Q.w / A.w; + + // update linear system based on attribute gradients (BB^T/a) + for (size_t k = 0; k < attribute_count; ++k) + { + const QuadricGrad& g = G[k]; + + Q.a00 -= (g.gx * g.gx) * iaw; + Q.a11 -= (g.gy * g.gy) * iaw; + Q.a22 -= (g.gz * g.gz) * iaw; + Q.a10 -= (g.gx * g.gy) * iaw; + Q.a20 -= (g.gx * g.gz) * iaw; + Q.a21 -= (g.gy * g.gz) * iaw; + + Q.b0 -= (g.gx * g.gw) * iaw; + Q.b1 -= (g.gy * g.gw) * iaw; + Q.b2 -= (g.gz * g.gw) * iaw; + } +} + +static void fillFaceQuadrics(Quadric* vertex_quadrics, QuadricGrad* volume_gradients, const unsigned int* indices, size_t index_count, const Vector3* vertex_positions, const unsigned int* remap) +{ + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int i0 = indices[i + 0]; + unsigned int i1 = indices[i + 1]; + unsigned int i2 = indices[i + 2]; + + Quadric Q; + quadricFromTriangle(Q, vertex_positions[i0], vertex_positions[i1], vertex_positions[i2], 1.f); + + quadricAdd(vertex_quadrics[remap[i0]], Q); + quadricAdd(vertex_quadrics[remap[i1]], Q); + quadricAdd(vertex_quadrics[remap[i2]], Q); + + if (volume_gradients) + { + QuadricGrad GV; + quadricVolumeGradient(GV, vertex_positions[i0], vertex_positions[i1], vertex_positions[i2]); + + quadricAdd(volume_gradients[remap[i0]], GV); + quadricAdd(volume_gradients[remap[i1]], GV); + quadricAdd(volume_gradients[remap[i2]], GV); + } + } +} + +static void fillVertexQuadrics(Quadric* vertex_quadrics, const Vector3* vertex_positions, size_t vertex_count, const unsigned int* remap, const unsigned char* vertex_lock, const unsigned int* sparse_remap, unsigned int options) +{ + // by default, we use a very small weight to improve triangulation and numerical stability without affecting the shape or error + float factor = (options & meshopt_SimplifyRegularizeLight) ? 1e-2f : ((options & meshopt_SimplifyRegularize) ? 1e-1f : 1e-7f); + + for (size_t i = 0; i < vertex_count; ++i) + { + if (remap[i] != i) + continue; + + // increase regularization weight for vertices marked as priority; for now we only examine the primary vertex + unsigned int ri = sparse_remap ? sparse_remap[i] : unsigned(i); + bool priority = vertex_lock && (vertex_lock[ri] & meshopt_SimplifyVertex_Priority) != 0; + + const Vector3& p = vertex_positions[i]; + float w = vertex_quadrics[i].w * (priority ? 1.0f : factor); + + Quadric Q; + quadricFromPoint(Q, p.x, p.y, p.z, w); + + quadricAdd(vertex_quadrics[i], Q); + } +} + +static void fillEdgeQuadrics(Quadric* vertex_quadrics, const unsigned int* indices, size_t index_count, const Vector3* vertex_positions, const unsigned int* remap, const unsigned char* vertex_kind, const unsigned int* loop, const unsigned int* loopback) +{ + for (size_t i = 0; i < index_count; i += 3) + { + static const int next[4] = {1, 2, 0, 1}; + + for (int e = 0; e < 3; ++e) + { + unsigned int i0 = indices[i + e]; + unsigned int i1 = indices[i + next[e]]; + + unsigned char k0 = vertex_kind[i0]; + unsigned char k1 = vertex_kind[i1]; + + // early out: manifold vertices never lie on a border + if (k0 == Kind_Manifold || k1 == Kind_Manifold) + continue; + + // one of the vertices must be border/seam/fringe; additional checks help validate the border + if (k0 != Kind_Border && k0 != Kind_Seam && k0 != Kind_Fringe && k1 != Kind_Border && k1 != Kind_Seam && k1 != Kind_Fringe) + continue; + + // if i0/i1 are border/seam we check if they are on the same edge loop + // note that we need to add the error even for edges that connect e.g. border & locked + // if we don't do that, the adjacent border->border edge won't have correct errors for corners + if ((k0 == Kind_Border || k0 == Kind_Seam) && loop[i0] != i1) + continue; + + if ((k1 == Kind_Border || k1 == Kind_Seam) && loopback[i1] != i0) + continue; + + // fringe vertices don't have proper edge loops, so we need to add edge quadrics to fringe-fringe or fringe-locked edges + // note that border-fringe edges are still processed, guarded by the loop check above + if ((k0 == Kind_Fringe || k1 == Kind_Fringe) && (k0 == Kind_Complex || k1 == Kind_Complex)) + continue; + + unsigned int i2 = indices[i + next[e + 1]]; + + // we try hard to maintain border edge geometry; seam edges can move more freely + // due to topological restrictions on collapses, seam quadrics slightly improve collapse structure but aren't critical + const float kEdgeWeightSeam = 0.5f; // applied twice due to opposite edges + const float kEdgeWeightBorder = 10.f; + + float edgeWeight = (k0 == Kind_Seam || k1 == Kind_Seam) ? kEdgeWeightSeam : kEdgeWeightBorder; + + Quadric Q; + quadricFromTriangleEdge(Q, vertex_positions[i0], vertex_positions[i1], vertex_positions[i2], edgeWeight); + + Quadric QT; + quadricFromTriangle(QT, vertex_positions[i0], vertex_positions[i1], vertex_positions[i2], edgeWeight); + + // mix edge quadric with triangle quadric to stabilize collapses in both directions; both quadrics inherit edge weight so that their error is added + QT.w = 0; + quadricAdd(Q, QT); + + quadricAdd(vertex_quadrics[remap[i0]], Q); + quadricAdd(vertex_quadrics[remap[i1]], Q); + } + } +} + +static unsigned int oppositeVertex(const EdgeAdjacency& adjacency, unsigned int a, unsigned int b, const unsigned int* remap, const unsigned int* wedge) +{ + unsigned int v = a; + unsigned int r = ~0u; + + do + { + unsigned int count = adjacency.offsets[v + 1] - adjacency.offsets[v]; + const EdgeAdjacency::Edge* edges = adjacency.data + adjacency.offsets[v]; + + for (size_t i = 0; i < count; ++i) + if (remap[edges[i].next] == remap[b]) + r = (r == ~0u) ? edges[i].prev : a; + + v = wedge[v]; + } while (v != a); + + // we only return the opposite vertex if it's unique + return r == a ? ~0u : r; +} + +static void fillFoldQuadrics(Quadric* vertex_quadrics, const EdgeAdjacency& adjacency, const Vector3* vertex_positions, size_t vertex_count, const unsigned int* remap, const unsigned int* wedge) +{ + for (size_t i0 = 0; i0 < vertex_count; ++i0) + for (unsigned int i = adjacency.offsets[i0]; i < adjacency.offsets[i0 + 1]; ++i) + { + unsigned int i1 = adjacency.data[i].next; + unsigned int i2 = adjacency.data[i].prev; + + // since we only process paired edges each edge should occur twice; skip redundant edges + if (remap[i1] > remap[i0]) + continue; + + unsigned int i3 = oppositeVertex(adjacency, i1, unsigned(i0), remap, wedge); + + if (i3 == ~0u) + continue; + + Vector3 p10 = {vertex_positions[i1].x - vertex_positions[i0].x, vertex_positions[i1].y - vertex_positions[i0].y, vertex_positions[i1].z - vertex_positions[i0].z}; + Vector3 p20 = {vertex_positions[i2].x - vertex_positions[i0].x, vertex_positions[i2].y - vertex_positions[i0].y, vertex_positions[i2].z - vertex_positions[i0].z}; + Vector3 p30 = {vertex_positions[i3].x - vertex_positions[i0].x, vertex_positions[i3].y - vertex_positions[i0].y, vertex_positions[i3].z - vertex_positions[i0].z}; + + Vector3 normal = {p10.y * p20.z - p10.z * p20.y, p10.z * p20.x - p10.x * p20.z, p10.x * p20.y - p10.y * p20.x}; + Vector3 opposite = {p30.y * p10.z - p30.z * p10.y, p30.z * p10.x - p30.x * p10.z, p30.x * p10.y - p30.y * p10.x}; + float nl = sqrtf(normal.x * normal.x + normal.y * normal.y + normal.z * normal.z); + float ol = sqrtf(opposite.x * opposite.x + opposite.y * opposite.y + opposite.z * opposite.z); + + // identify folds by checking dihedral angle against a somewhat arbitrary ~30 degree cutoff + if (normal.x * opposite.x + normal.y * opposite.y + normal.z * opposite.z >= -0.85f * nl * ol) + continue; + + // use both triangles for symmetry since we won't visit this edge again + Quadric Q, QO; + quadricFromTriangleEdge(Q, vertex_positions[i0], vertex_positions[i1], vertex_positions[i2], 1.f); + quadricFromTriangleEdge(QO, vertex_positions[i0], vertex_positions[i1], vertex_positions[i3], 1.f); + quadricAdd(Q, QO); + + quadricAdd(vertex_quadrics[remap[i0]], Q); + quadricAdd(vertex_quadrics[remap[i1]], Q); + } +} + +static void fillAttributeQuadrics(Quadric* attribute_quadrics, QuadricGrad* attribute_gradients, const unsigned int* indices, size_t index_count, const Vector3* vertex_positions, const float* vertex_attributes, size_t attribute_count) +{ + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int i0 = indices[i + 0]; + unsigned int i1 = indices[i + 1]; + unsigned int i2 = indices[i + 2]; + + Quadric QA; + QuadricGrad G[kMaxAttributes]; + quadricFromAttributes(QA, G, vertex_positions[i0], vertex_positions[i1], vertex_positions[i2], &vertex_attributes[i0 * attribute_count], &vertex_attributes[i1 * attribute_count], &vertex_attributes[i2 * attribute_count], attribute_count); + + quadricAdd(attribute_quadrics[i0], QA); + quadricAdd(attribute_quadrics[i1], QA); + quadricAdd(attribute_quadrics[i2], QA); + + quadricAdd(&attribute_gradients[i0 * attribute_count], G, attribute_count); + quadricAdd(&attribute_gradients[i1 * attribute_count], G, attribute_count); + quadricAdd(&attribute_gradients[i2 * attribute_count], G, attribute_count); + } +} + +// does triangle ABC flip when C is replaced with D? +static bool hasTriangleFlip(const Vector3& a, const Vector3& b, const Vector3& c, const Vector3& d) +{ + Vector3 eb = {b.x - a.x, b.y - a.y, b.z - a.z}; + Vector3 ec = {c.x - a.x, c.y - a.y, c.z - a.z}; + Vector3 ed = {d.x - a.x, d.y - a.y, d.z - a.z}; + + Vector3 nbc = {eb.y * ec.z - eb.z * ec.y, eb.z * ec.x - eb.x * ec.z, eb.x * ec.y - eb.y * ec.x}; + Vector3 nbd = {eb.y * ed.z - eb.z * ed.y, eb.z * ed.x - eb.x * ed.z, eb.x * ed.y - eb.y * ed.x}; + + float ndp = nbc.x * nbd.x + nbc.y * nbd.y + nbc.z * nbd.z; + float abc = nbc.x * nbc.x + nbc.y * nbc.y + nbc.z * nbc.z; + float abd = nbd.x * nbd.x + nbd.y * nbd.y + nbd.z * nbd.z; + + // scale is cos(angle); somewhat arbitrarily set to ~75 degrees + // note that the "pure" check is ndp <= 0 (90 degree cutoff) but that allows flipping through a series of close-to-90 collapses + return ndp <= 0.25f * sqrtf(abc * abd); +} + +static bool hasTriangleFlips(const EdgeAdjacency& adjacency, const Vector3* vertex_positions, const unsigned int* collapse_remap, unsigned int i0, unsigned int i1) +{ + assert(collapse_remap[i0] == i0); + assert(collapse_remap[i1] == i1); + + const Vector3& v0 = vertex_positions[i0]; + const Vector3& v1 = vertex_positions[i1]; + + const EdgeAdjacency::Edge* edges = &adjacency.data[adjacency.offsets[i0]]; + size_t count = adjacency.offsets[i0 + 1] - adjacency.offsets[i0]; + + for (size_t i = 0; i < count; ++i) + { + unsigned int a = collapse_remap[edges[i].next]; + unsigned int b = collapse_remap[edges[i].prev]; + + // skip triangles that will get collapsed by i0->i1 collapse or already got collapsed previously + if (a == i1 || b == i1 || a == b) + continue; + + // early-out when at least one triangle flips due to a collapse + if (hasTriangleFlip(vertex_positions[a], vertex_positions[b], v0, v1)) + return true; + } + + return false; +} + +static bool hasTriangleFlips(const EdgeAdjacency& adjacency, const Vector3* vertex_positions, unsigned int i0, const Vector3& v1) +{ + const Vector3& v0 = vertex_positions[i0]; + + const EdgeAdjacency::Edge* edges = &adjacency.data[adjacency.offsets[i0]]; + size_t count = adjacency.offsets[i0 + 1] - adjacency.offsets[i0]; + + for (size_t i = 0; i < count; ++i) + { + unsigned int a = edges[i].next, b = edges[i].prev; + + if (hasTriangleFlip(vertex_positions[a], vertex_positions[b], v0, v1)) + return true; + } + + return false; +} + +static float getNeighborhoodRadius(const EdgeAdjacency& adjacency, const Vector3* vertex_positions, unsigned int i0) +{ + const Vector3& v0 = vertex_positions[i0]; + + const EdgeAdjacency::Edge* edges = &adjacency.data[adjacency.offsets[i0]]; + size_t count = adjacency.offsets[i0 + 1] - adjacency.offsets[i0]; + + float result = 0.f; + + for (size_t i = 0; i < count; ++i) + { + unsigned int a = edges[i].next, b = edges[i].prev; + + const Vector3& va = vertex_positions[a]; + const Vector3& vb = vertex_positions[b]; + + float da = (va.x - v0.x) * (va.x - v0.x) + (va.y - v0.y) * (va.y - v0.y) + (va.z - v0.z) * (va.z - v0.z); + float db = (vb.x - v0.x) * (vb.x - v0.x) + (vb.y - v0.y) * (vb.y - v0.y) + (vb.z - v0.z) * (vb.z - v0.z); + + result = result < da ? da : result; + result = result < db ? db : result; + } + + return sqrtf(result); +} + +static unsigned int getComplexTarget(unsigned int v, unsigned int target, const unsigned int* remap, const unsigned int* loop, const unsigned int* loopback) +{ + unsigned int r = remap[target]; + + // use loop metadata to guide complex collapses towards the correct wedge + // this works for edges on attribute discontinuities because loop/loopback track the single half-edge without a pair, similar to seams + if (loop[v] != ~0u && remap[loop[v]] == r) + return loop[v]; + else if (loopback[v] != ~0u && remap[loopback[v]] == r) + return loopback[v]; + else + return target; +} + +static size_t boundEdgeCollapses(const EdgeAdjacency& adjacency, size_t vertex_count, size_t index_count, unsigned char* vertex_kind) +{ + size_t dual_count = 0; + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned char k = vertex_kind[i]; + unsigned int e = adjacency.offsets[i + 1] - adjacency.offsets[i]; + + dual_count += (k == Kind_Manifold || k == Kind_Seam) ? e : 0; + } + + assert(dual_count <= index_count); + + // pad capacity by 3 so that we can check for overflow once per triangle instead of once per edge + return (index_count - dual_count / 2) + 3; +} + +static size_t pickEdgeCollapses(Collapse* collapses, size_t collapse_capacity, const unsigned int* indices, size_t index_count, const unsigned int* remap, const unsigned char* vertex_kind, const unsigned int* loop, const unsigned int* loopback) +{ + size_t collapse_count = 0; + + for (size_t i = 0; i < index_count; i += 3) + { + static const int next[3] = {1, 2, 0}; + + // this should never happen as boundEdgeCollapses should give an upper bound for the collapse count, but in an unlikely event it does we can just drop extra collapses + if (collapse_count + 3 > collapse_capacity) + break; + + for (int e = 0; e < 3; ++e) + { + unsigned int i0 = indices[i + e]; + unsigned int i1 = indices[i + next[e]]; + + // this can happen either when input has a zero-length edge, or when we perform collapses for complex + // topology w/seams and collapse a manifold vertex that connects to both wedges onto one of them + // we leave edges like this alone since they may be important for preserving mesh integrity + if (remap[i0] == remap[i1]) + continue; + + unsigned char k0 = vertex_kind[i0]; + unsigned char k1 = vertex_kind[i1]; + + // the edge has to be collapsible in at least one direction + if (!(kCanCollapse[k0][k1] | kCanCollapse[k1][k0])) + continue; + + // manifold and seam edges should occur twice (i0->i1 and i1->i0) - skip redundant edges + if (kHasOpposite[k0][k1] && remap[i1] > remap[i0]) + continue; + + // two vertices are on a border or a seam, but there's no direct edge between them + // this indicates that they belong to two different edge loops and we should not collapse this edge + // loop[] and loopback[] track half edges so we only need to check one of them + if ((k0 == Kind_Border || k0 == Kind_Seam) && k1 != Kind_Manifold && loop[i0] != i1) + continue; + if ((k1 == Kind_Border || k1 == Kind_Seam) && k0 != Kind_Manifold && loopback[i1] != i0) + continue; + + // edge can be collapsed in either direction - we will pick the one with minimum error + // note: we evaluate error later during collapse ranking, here we just tag the edge as bidirectional + if (kCanCollapse[k0][k1] & kCanCollapse[k1][k0]) + { + Collapse c = {i0, i1, {/* bidi= */ 1}}; + collapses[collapse_count++] = c; + } + else + { + // edge can only be collapsed in one direction + unsigned int e0 = kCanCollapse[k0][k1] ? i0 : i1; + unsigned int e1 = kCanCollapse[k0][k1] ? i1 : i0; + + Collapse c = {e0, e1, {/* bidi= */ 0}}; + collapses[collapse_count++] = c; + } + } + } + + return collapse_count; +} + +static void rankEdgeCollapses(Collapse* collapses, size_t collapse_count, const Vector3* vertex_positions, const float* vertex_attributes, const Quadric* vertex_quadrics, const Quadric* attribute_quadrics, const QuadricGrad* attribute_gradients, size_t attribute_count, const unsigned int* remap, const unsigned int* wedge, const unsigned char* vertex_kind, const unsigned int* loop, const unsigned int* loopback, bool clamped) +{ + for (size_t i = 0; i < collapse_count; ++i) + { + Collapse& c = collapses[i]; + + unsigned int i0 = c.v0; + unsigned int i1 = c.v1; + bool bidi = c.bidi; + + float ei = quadricError(vertex_quadrics[remap[i0]], vertex_positions[i1]); + float ej = bidi ? quadricError(vertex_quadrics[remap[i1]], vertex_positions[i0]) : FLT_MAX; + + if (attribute_count) + { + ei += quadricError(attribute_quadrics[i0], &attribute_gradients[i0 * attribute_count], attribute_count, vertex_positions[i1], &vertex_attributes[i1 * attribute_count], clamped); + ej += bidi ? quadricError(attribute_quadrics[i1], &attribute_gradients[i1 * attribute_count], attribute_count, vertex_positions[i0], &vertex_attributes[i0 * attribute_count], clamped) : 0; + + // seam edges need to aggregate attribute errors between primary and secondary edges, as attribute quadrics are separate + if (vertex_kind[i0] == Kind_Seam) + { + // for seam collapses we need to find the seam pair; this is a bit tricky since we need to rely on edge loops as target vertex may be locked (and thus have more than two wedges) + unsigned int s0 = wedge[i0]; + unsigned int s1 = loop[i0] == i1 ? loopback[s0] : loop[s0]; + + assert(wedge[s0] == i0); // s0 may be equal to i0 for half-seams + assert(s1 != ~0u && remap[s1] == remap[i1]); + + // note: this should never happen due to the assertion above, but when disabled if we ever hit this case we'll get a memory safety issue; for now play it safe + s1 = (s1 != ~0u) ? s1 : wedge[i1]; + + ei += quadricError(attribute_quadrics[s0], &attribute_gradients[s0 * attribute_count], attribute_count, vertex_positions[s1], &vertex_attributes[s1 * attribute_count], clamped); + ej += bidi ? quadricError(attribute_quadrics[s1], &attribute_gradients[s1 * attribute_count], attribute_count, vertex_positions[s0], &vertex_attributes[s0 * attribute_count], clamped) : 0; + } + else + { + // complex edges can have multiple wedges, so we need to aggregate errors for all wedges based on the selected target + if (vertex_kind[i0] == Kind_Complex || vertex_kind[i0] == Kind_Fringe) + for (unsigned int v = wedge[i0]; v != i0; v = wedge[v]) + { + unsigned int t = getComplexTarget(v, i1, remap, loop, loopback); + + ei += quadricError(attribute_quadrics[v], &attribute_gradients[v * attribute_count], attribute_count, vertex_positions[t], &vertex_attributes[t * attribute_count], clamped); + } + + if ((vertex_kind[i1] == Kind_Complex || vertex_kind[i1] == Kind_Fringe) && bidi) + for (unsigned int v = wedge[i1]; v != i1; v = wedge[v]) + { + unsigned int t = getComplexTarget(v, i0, remap, loop, loopback); + + ej += quadricError(attribute_quadrics[v], &attribute_gradients[v * attribute_count], attribute_count, vertex_positions[t], &vertex_attributes[t * attribute_count], clamped); + } + } + } + + // pick edge direction with minimal error (branchless) + bool rev = bidi & (ej < ei); + + c.v0 = rev ? i1 : i0; + c.v1 = rev ? i0 : i1; + c.error = ej < ei ? ej : ei; + } +} + +static void sortEdgeCollapses(unsigned int* sort_order, const Collapse* collapses, size_t collapse_count) +{ + // we use counting sort to order collapses by error; since the exact sort order is not as critical, + // only top 12 bits of exponent+mantissa (8 bits of exponent and 4 bits of mantissa) are used. + // to avoid excessive stack usage, we clamp the exponent range as collapses with errors much higher than 1 are not useful. + const unsigned int sort_bits = 12; + const unsigned int sort_bins = 2048 + 512; // exponent range [-127, 32) + + // fill histogram for counting sort + unsigned int histogram[sort_bins]; + memset(histogram, 0, sizeof(histogram)); + + for (size_t i = 0; i < collapse_count; ++i) + { + // skip sign bit since error is non-negative + unsigned int error = collapses[i].errorui; + unsigned int key = (error << 1) >> (32 - sort_bits); + key = key < sort_bins ? key : sort_bins - 1; + + histogram[key]++; + } + + // compute offsets based on histogram data + size_t histogram_sum = 0; + + for (size_t i = 0; i < sort_bins; ++i) + { + size_t count = histogram[i]; + histogram[i] = unsigned(histogram_sum); + histogram_sum += count; + } + + assert(histogram_sum == collapse_count); + + // compute sort order based on offsets + for (size_t i = 0; i < collapse_count; ++i) + { + // skip sign bit since error is non-negative + unsigned int error = collapses[i].errorui; + unsigned int key = (error << 1) >> (32 - sort_bits); + key = key < sort_bins ? key : sort_bins - 1; + + sort_order[histogram[key]++] = unsigned(i); + } +} + +static size_t performEdgeCollapses(unsigned int* collapse_remap, unsigned char* collapse_locked, const Collapse* collapses, size_t collapse_count, const unsigned int* collapse_order, const unsigned int* remap, const unsigned int* wedge, const unsigned char* vertex_kind, const unsigned int* loop, const unsigned int* loopback, const Vector3* vertex_positions, const EdgeAdjacency& adjacency, size_t triangle_collapse_goal, float error_limit, float& result_error) +{ + size_t edge_collapses = 0; + size_t triangle_collapses = 0; + + // most collapses remove 2 triangles; use this to establish a bound on the pass in terms of error limit + // note that edge_collapse_goal is an estimate; triangle_collapse_goal will be used to actually limit collapses + size_t edge_collapse_goal = triangle_collapse_goal / 2; + +#if TRACE + size_t stats[7] = {}; +#endif + + for (size_t i = 0; i < collapse_count; ++i) + { + const Collapse& c = collapses[collapse_order[i]]; + + TRACESTATS(0); + + if (c.error > error_limit) + { + TRACESTATS(4); + break; + } + + if (triangle_collapses >= triangle_collapse_goal) + { + TRACESTATS(5); + break; + } + + // we limit the error in each pass based on the error of optimal last collapse; since many collapses will be locked + // as they will share vertices with other successful collapses, we need to increase the acceptable error by some factor + float error_goal = edge_collapse_goal < collapse_count ? 1.5f * collapses[collapse_order[edge_collapse_goal]].error : FLT_MAX; + + // on average, each collapse is expected to lock 6 other collapses; to avoid degenerate passes on meshes with odd + // topology, we only abort if we got over 1/6 collapses accordingly. + if (c.error > error_goal && c.error > result_error && triangle_collapses > triangle_collapse_goal / 6) + { + TRACESTATS(6); + break; + } + + unsigned int i0 = c.v0; + unsigned int i1 = c.v1; + + unsigned int r0 = remap[i0]; + unsigned int r1 = remap[i1]; + + unsigned char kind = vertex_kind[i0]; + + // we don't collapse vertices that had source or target vertex involved in a collapse + // it's important to not move the vertices twice since it complicates the tracking/remapping logic + // it's important to not move other vertices towards a moved vertex to preserve error since we don't re-rank collapses mid-pass + if (collapse_locked[r0] | collapse_locked[r1]) + { + TRACESTATS(1); + continue; + } + + if (hasTriangleFlips(adjacency, vertex_positions, collapse_remap, r0, r1)) + { + // adjust collapse goal since this collapse is invalid and shouldn't factor into error goal + edge_collapse_goal++; + + TRACESTATS(2); + continue; + } + + assert(collapse_remap[r0] == r0); + assert(collapse_remap[r1] == r1); + + if (kind == Kind_Complex || kind == Kind_Fringe) + { + collapse_remap[i0] = i1; + + // remap all vertices in the complex to the target vertex, using the same ranking that we used to evaluate the collapses + for (unsigned int v = wedge[i0]; v != i0; v = wedge[v]) + collapse_remap[v] = getComplexTarget(v, i1, remap, loop, loopback); + } + else if (kind == Kind_Seam) + { + // for seam collapses we need to move the seam pair together; this is a bit tricky since we need to rely on edge loops as target vertex may be locked (and thus have more than two wedges) + unsigned int s0 = wedge[i0]; + unsigned int s1 = loop[i0] == i1 ? loopback[s0] : loop[s0]; + assert(wedge[s0] == i0); // s0 may be equal to i0 for half-seams + assert(s1 != ~0u && remap[s1] == r1); + + // additional asserts to verify that the seam pair is consistent + assert(kind != vertex_kind[i1] || s1 == wedge[i1]); + assert(loop[i0] == i1 || loopback[i0] == i1); + assert(loop[s0] == s1 || loopback[s0] == s1); + + // note: this should never happen due to the assertion above, but when disabled if we ever hit this case we'll get a memory safety issue; for now play it safe + s1 = (s1 != ~0u) ? s1 : wedge[i1]; + + collapse_remap[i0] = i1; + collapse_remap[s0] = s1; + } + else + { + assert(wedge[i0] == i0); + + collapse_remap[i0] = i1; + } + + // note: we technically don't need to lock r1 if it's a locked vertex, as it can't move and its quadric won't be used + // however, this results in slightly worse error on some meshes because the locked collapses get an unfair advantage wrt scheduling + collapse_locked[r0] = 1; + collapse_locked[r1] = 1; + + // border edges collapse 1 triangle, other edges collapse 2 or more + // fringe edges often collapse 1 but may also collapse 2 because they don't respect loops, so we conservatively assume 2 + triangle_collapses += (kind == Kind_Border) ? 1 : 2; + edge_collapses++; + + result_error = result_error < c.error ? c.error : result_error; + } + +#if TRACE + float error_goal_last = edge_collapse_goal < collapse_count ? 1.5f * collapses[collapse_order[edge_collapse_goal]].error : FLT_MAX; + float error_goal_limit = error_goal_last < error_limit ? error_goal_last : error_limit; + + printf("removed %d triangles, error %e (goal %e); evaluated %d/%d collapses (done %d, skipped %d, invalid %d); %s\n", + int(triangle_collapses), sqrtf(result_error), sqrtf(error_goal_limit), + int(stats[0]), int(collapse_count), int(edge_collapses), int(stats[1]), int(stats[2]), + stats[4] ? "error limit" : (stats[5] ? "count limit" : (stats[6] ? "error goal" : "out of collapses"))); +#endif + + return edge_collapses; +} + +static void updateQuadrics(const unsigned int* collapse_remap, size_t vertex_count, Quadric* vertex_quadrics, QuadricGrad* volume_gradients, Quadric* attribute_quadrics, QuadricGrad* attribute_gradients, size_t attribute_count, const Vector3* vertex_positions, const unsigned int* remap, float& vertex_error) +{ + for (size_t i = 0; i < vertex_count; ++i) + { + if (collapse_remap[i] == i) + continue; + + unsigned int i0 = unsigned(i); + unsigned int i1 = collapse_remap[i]; + + unsigned int r0 = remap[i0]; + unsigned int r1 = remap[i1]; + + // ensure we only update vertex_quadrics once: primary vertex must be moved if any wedge is moved + if (i0 == r0) + { + quadricAdd(vertex_quadrics[r1], vertex_quadrics[r0]); + + if (volume_gradients) + quadricAdd(volume_gradients[r1], volume_gradients[r0]); + } + + if (attribute_count) + { + quadricAdd(attribute_quadrics[i1], attribute_quadrics[i0]); + quadricAdd(&attribute_gradients[i1 * attribute_count], &attribute_gradients[i0 * attribute_count], attribute_count); + + if (i0 == r0) + { + // when attributes are used, distance error needs to be recomputed as collapses don't track it; it is safe to do this after the quadric adjustment + float derr = quadricError(vertex_quadrics[r0], vertex_positions[r1]); + vertex_error = vertex_error < derr ? derr : vertex_error; + } + } + } +} + +static void solvePositions(Vector3* vertex_positions, size_t vertex_count, const Quadric* vertex_quadrics, const QuadricGrad* volume_gradients, const Quadric* attribute_quadrics, const QuadricGrad* attribute_gradients, size_t attribute_count, const unsigned int* remap, const unsigned int* wedge, const EdgeAdjacency& adjacency, const unsigned char* vertex_kind, const unsigned char* vertex_update) +{ +#if TRACE + size_t stats[6] = {}; +#endif + + for (size_t i = 0; i < vertex_count; ++i) + { + if (!vertex_update[i]) + continue; + + // moving vertices on an attribute discontinuity may result in extrapolating UV outside of the chart bounds + // moving vertices on a border requires a stronger edge quadric to preserve the border geometry + if (vertex_kind[i] != Kind_Manifold && vertex_kind[i] != Kind_Complex) + continue; + + if (remap[i] != i) + { + vertex_positions[i] = vertex_positions[remap[i]]; + continue; + } + + TRACESTATS(0); + + const Vector3& vp = vertex_positions[i]; + + Quadric Q = vertex_quadrics[i]; + QuadricGrad GV = {}; + + // add a point quadric for regularization to stabilize the solution + Quadric R; + quadricFromPoint(R, vp.x, vp.y, vp.z, Q.w * 1e-4f); + quadricAdd(Q, R); + + if (attribute_count) + { + // optimal point simultaneously minimizes attribute quadrics for all wedges + unsigned int v = unsigned(i); + do + { + quadricReduceAttributes(Q, attribute_quadrics[v], &attribute_gradients[v * attribute_count], attribute_count); + v = wedge[v]; + } while (v != i); + + // minimizing attribute quadrics results in volume loss so we incorporate volume gradient as a constraint + if (volume_gradients) + GV = volume_gradients[i]; + } + + Vector3 p; + if (!quadricSolve(p, Q, GV)) + { + TRACESTATS(2); + continue; + } + + // reject updates that move the vertex too far from its neighborhood + // this detects and fixes most cases when the quadric is not well-defined + float nr = getNeighborhoodRadius(adjacency, vertex_positions, unsigned(i)); + float dp = (p.x - vp.x) * (p.x - vp.x) + (p.y - vp.y) * (p.y - vp.y) + (p.z - vp.z) * (p.z - vp.z); + + if (dp > nr * nr) + { + TRACESTATS(3); + continue; + } + + // reject updates that would flip a neighboring triangle, as we do for edge collapse + if (hasTriangleFlips(adjacency, vertex_positions, unsigned(i), p)) + { + TRACESTATS(4); + continue; + } + + // reject updates that increase positional error too much; allow some tolerance to improve attribute quality + if (quadricError(vertex_quadrics[i], p) > quadricError(vertex_quadrics[i], vp) * 1.5f + 1e-6f) + { + TRACESTATS(5); + continue; + } + + TRACESTATS(1); + vertex_positions[i] = p; + } + +#if TRACE + printf("updated %d/%d positions; failed solve %d bounds %d flip %d error %d\n", int(stats[1]), int(stats[0]), int(stats[2]), int(stats[3]), int(stats[4]), int(stats[5])); +#endif +} + +static void solveAttributes(Vector3* vertex_positions, float* vertex_attributes, size_t vertex_count, const Quadric* attribute_quadrics, const QuadricGrad* attribute_gradients, size_t attribute_count, const unsigned int* remap, const unsigned int* wedge, const unsigned char* vertex_kind, const unsigned char* vertex_update) +{ + for (size_t i = 0; i < vertex_count; ++i) + { + if (!vertex_update[i]) + continue; + + if (remap[i] != i) + continue; + + for (size_t k = 0; k < attribute_count; ++k) + { + unsigned int shared = ~0u; + + // for complex vertices, preserve attribute continuity and use highest weight wedge if values were shared + if (vertex_kind[i] == Kind_Complex || vertex_kind[i] == Kind_Fringe) + { + shared = unsigned(i); + + for (unsigned int v = wedge[i]; v != i; v = wedge[v]) + if (vertex_attributes[v * attribute_count + k] != vertex_attributes[i * attribute_count + k]) + shared = ~0u; + else if (shared != ~0u && attribute_quadrics[v].w > attribute_quadrics[shared].w) + shared = v; + } + + // update attributes for all wedges + unsigned int v = unsigned(i); + do + { + unsigned int r = (shared == ~0u) ? v : shared; + + const Vector3& p = vertex_positions[i]; // same for all wedges + const Quadric& A = attribute_quadrics[r]; + const QuadricGrad& G = attribute_gradients[r * attribute_count + k]; + + float iw = A.w == 0 ? 0.f : 1.f / A.w; + float av = (G.gx * p.x + G.gy * p.y + G.gz * p.z + G.gw) * iw; + + vertex_attributes[v * attribute_count + k] = av; + v = wedge[v]; + } while (v != i); + } + } +} + +static size_t remapIndexBuffer(unsigned int* indices, size_t index_count, const unsigned int* collapse_remap, const unsigned int* remap) +{ + size_t write = 0; + + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int v0 = collapse_remap[indices[i + 0]]; + unsigned int v1 = collapse_remap[indices[i + 1]]; + unsigned int v2 = collapse_remap[indices[i + 2]]; + + // we never move the vertex twice during a single pass + assert(collapse_remap[v0] == v0); + assert(collapse_remap[v1] == v1); + assert(collapse_remap[v2] == v2); + + // collapse zero area triangles even if they are not topologically degenerate + // this is required to cleanup manifold->seam collapses when a vertex is collapsed onto a seam pair + // as well as complex collapses and some other cases where cross wedge collapses are performed + unsigned int r0 = remap[v0]; + unsigned int r1 = remap[v1]; + unsigned int r2 = remap[v2]; + + if (r0 != r1 && r0 != r2 && r1 != r2) + { + indices[write + 0] = v0; + indices[write + 1] = v1; + indices[write + 2] = v2; + write += 3; + } + } + + return write; +} + +static void remapEdgeLoops(unsigned int* loop, size_t vertex_count, const unsigned int* collapse_remap) +{ + for (size_t i = 0; i < vertex_count; ++i) + { + // note: this is a no-op for vertices that were remapped + // ideally we would clear the loop entries for those for consistency, even though they aren't going to be used + // however, the remapping process needs loop information for remapped vertices, so this would require a separate pass + if (loop[i] != ~0u) + { + unsigned int l = loop[i]; + unsigned int r = collapse_remap[l]; + + // i == r is a special case when the seam edge is collapsed in a direction opposite to where loop goes + if (i == r) + loop[i] = (loop[l] != ~0u) ? collapse_remap[loop[l]] : ~0u; + else + loop[i] = r; + } + } +} + +static unsigned int follow(unsigned int* parents, unsigned int index) +{ + while (index != parents[index]) + { + unsigned int parent = parents[index]; + parents[index] = parents[parent]; + index = parent; + } + + return index; +} + +static size_t buildComponents(unsigned int* components, size_t vertex_count, const unsigned int* indices, size_t index_count, const unsigned int* remap) +{ + for (size_t i = 0; i < vertex_count; ++i) + components[i] = unsigned(i); + + // compute a unique (but not sequential!) index for each component via union-find + for (size_t i = 0; i < index_count; i += 3) + { + static const int next[4] = {1, 2, 0, 1}; + + for (int e = 0; e < 3; ++e) + { + unsigned int i0 = indices[i + e]; + unsigned int i1 = indices[i + next[e]]; + + unsigned int r0 = remap[i0]; + unsigned int r1 = remap[i1]; + + r0 = follow(components, r0); + r1 = follow(components, r1); + + // merge components with larger indices into components with smaller indices + // this guarantees that the root of the component is always the one with the smallest index + if (r0 != r1) + components[r0 < r1 ? r1 : r0] = r0 < r1 ? r0 : r1; + } + } + + // make sure each element points to the component root *before* we renumber the components + for (size_t i = 0; i < vertex_count; ++i) + if (remap[i] == i) + components[i] = follow(components, unsigned(i)); + + unsigned int next_component = 0; + + // renumber components using sequential indices + // a sequential pass is sufficient because component root always has the smallest index + // note: it is unsafe to use follow() in this pass because we're replacing component links with sequential indices inplace + for (size_t i = 0; i < vertex_count; ++i) + { + if (remap[i] == i) + { + unsigned int root = components[i]; + assert(root <= i); // make sure we already computed the component for non-roots + components[i] = (root == i) ? next_component++ : components[root]; + } + else + { + assert(remap[i] < i); // make sure we already computed the component + components[i] = components[remap[i]]; + } + } + + return next_component; +} + +static void measureComponents(float* component_errors, size_t component_count, const unsigned int* components, const Vector3* vertex_positions, size_t vertex_count) +{ + memset(component_errors, 0, component_count * 4 * sizeof(float)); + + // compute approximate sphere center for each component as an average + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int c = components[i]; + assert(components[i] < component_count); + + Vector3 v = vertex_positions[i]; // copy avoids aliasing issues + + component_errors[c * 4 + 0] += v.x; + component_errors[c * 4 + 1] += v.y; + component_errors[c * 4 + 2] += v.z; + component_errors[c * 4 + 3] += 1; // weight + } + + // complete the center computation, and reinitialize [3] as a radius + for (size_t i = 0; i < component_count; ++i) + { + float w = component_errors[i * 4 + 3]; + float iw = w == 0.f ? 0.f : 1.f / w; + + component_errors[i * 4 + 0] *= iw; + component_errors[i * 4 + 1] *= iw; + component_errors[i * 4 + 2] *= iw; + component_errors[i * 4 + 3] = 0; // radius + } + + // compute squared radius for each component + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int c = components[i]; + + float dx = vertex_positions[i].x - component_errors[c * 4 + 0]; + float dy = vertex_positions[i].y - component_errors[c * 4 + 1]; + float dz = vertex_positions[i].z - component_errors[c * 4 + 2]; + float r = dx * dx + dy * dy + dz * dz; + + component_errors[c * 4 + 3] = component_errors[c * 4 + 3] < r ? r : component_errors[c * 4 + 3]; + } + + // we've used the output buffer as scratch space, so we need to move the results to proper indices + // note: we keep the squared error to make it match quadric error metric + for (size_t i = 0; i < component_count; ++i) + component_errors[i] = component_errors[i * 4 + 3]; +} + +static size_t pruneComponents(unsigned int* indices, size_t index_count, const unsigned int* components, const float* component_errors, size_t component_count, float error_cutoff, float& nexterror) +{ + (void)component_count; + + size_t write = 0; + float min_error = FLT_MAX; + + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int v0 = indices[i + 0], v1 = indices[i + 1], v2 = indices[i + 2]; + unsigned int c = components[v0]; + assert(c == components[v1] && c == components[v2]); + + if (component_errors[c] > error_cutoff) + { + min_error = min_error > component_errors[c] ? component_errors[c] : min_error; + + indices[write + 0] = v0; + indices[write + 1] = v1; + indices[write + 2] = v2; + write += 3; + } + } + +#if TRACE + size_t pruned_components = 0; + for (size_t i = 0; i < component_count; ++i) + pruned_components += (component_errors[i] >= nexterror && component_errors[i] <= error_cutoff); + + printf("pruned %d triangles in %d components (goal %e); next %e\n", int((index_count - write) / 3), int(pruned_components), sqrtf(error_cutoff), min_error < FLT_MAX ? sqrtf(min_error) : min_error * 2); +#endif + + // update next error with the smallest error of the remaining components + nexterror = min_error; + return write; +} + +struct CellHasher +{ + const unsigned int* vertex_ids; + + size_t hash(unsigned int i) const + { + unsigned int h = vertex_ids ? vertex_ids[i] : unsigned(i); + + // MurmurHash2 finalizer + h ^= h >> 13; + h *= 0x5bd1e995; + h ^= h >> 15; + return h; + } + + bool equal(unsigned int lhs, unsigned int rhs) const + { + return vertex_ids ? vertex_ids[lhs] == vertex_ids[rhs] : lhs == rhs; + } +}; + +struct TriangleHasher +{ + const unsigned int* indices; + + size_t hash(unsigned int i) const + { + const unsigned int* tri = indices + i * 3; + + // Optimized Spatial Hashing for Collision Detection of Deformable Objects + return (tri[0] * 73856093) ^ (tri[1] * 19349663) ^ (tri[2] * 83492791); + } + + bool equal(unsigned int lhs, unsigned int rhs) const + { + const unsigned int* lt = indices + lhs * 3; + const unsigned int* rt = indices + rhs * 3; + + return lt[0] == rt[0] && lt[1] == rt[1] && lt[2] == rt[2]; + } +}; + +static void computeVertexIds(unsigned int* vertex_ids, const Vector3* vertex_positions, const unsigned char* vertex_lock, size_t vertex_count, int grid_size) +{ + assert(grid_size >= 1 && grid_size <= 1024); + float cell_scale = float(grid_size - 1); + + for (size_t i = 0; i < vertex_count; ++i) + { + const Vector3& v = vertex_positions[i]; + + int xi = int(v.x * cell_scale + 0.5f); + int yi = int(v.y * cell_scale + 0.5f); + int zi = int(v.z * cell_scale + 0.5f); + + if (vertex_lock && (vertex_lock[i] & meshopt_SimplifyVertex_Lock)) + vertex_ids[i] = (1 << 30) | unsigned(i); + else + vertex_ids[i] = (xi << 20) | (yi << 10) | zi; + } +} + +static size_t countTriangles(const unsigned int* vertex_ids, const unsigned int* indices, size_t index_count) +{ + size_t result = 0; + + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int id0 = vertex_ids[indices[i + 0]]; + unsigned int id1 = vertex_ids[indices[i + 1]]; + unsigned int id2 = vertex_ids[indices[i + 2]]; + + result += (id0 != id1) & (id0 != id2) & (id1 != id2); + } + + return result; +} + +static size_t fillVertexCells(unsigned int* table, size_t table_size, unsigned int* vertex_cells, const unsigned int* vertex_ids, size_t vertex_count) +{ + CellHasher hasher = {vertex_ids}; + + memset(table, -1, table_size * sizeof(unsigned int)); + + size_t result = 0; + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int* entry = hashLookup2(table, table_size, hasher, unsigned(i), ~0u); + + if (*entry == ~0u) + { + *entry = unsigned(i); + vertex_cells[i] = unsigned(result++); + } + else + { + vertex_cells[i] = vertex_cells[*entry]; + } + } + + return result; +} + +static size_t countVertexCells(unsigned int* table, size_t table_size, const unsigned int* vertex_ids, size_t vertex_count) +{ + CellHasher hasher = {NULL}; + + memset(table, -1, table_size * sizeof(unsigned int)); + + size_t result = 0; + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int id = vertex_ids[i]; + unsigned int* entry = hashLookup2(table, table_size, hasher, id, ~0u); + + result += (*entry == ~0u); + *entry = id; + } + + return result; +} + +static void fillCellQuadrics(Quadric* cell_quadrics, const unsigned int* indices, size_t index_count, const Vector3* vertex_positions, const unsigned int* vertex_cells) +{ + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int i0 = indices[i + 0]; + unsigned int i1 = indices[i + 1]; + unsigned int i2 = indices[i + 2]; + + unsigned int c0 = vertex_cells[i0]; + unsigned int c1 = vertex_cells[i1]; + unsigned int c2 = vertex_cells[i2]; + + int single_cell = (c0 == c1) & (c0 == c2); + + Quadric Q; + quadricFromTriangle(Q, vertex_positions[i0], vertex_positions[i1], vertex_positions[i2], single_cell ? 3.f : 1.f); + + if (single_cell) + { + quadricAdd(cell_quadrics[c0], Q); + } + else + { + quadricAdd(cell_quadrics[c0], Q); + quadricAdd(cell_quadrics[c1], Q); + quadricAdd(cell_quadrics[c2], Q); + } + } +} + +static void fillCellReservoirs(Reservoir* cell_reservoirs, size_t cell_count, const Vector3* vertex_positions, const float* vertex_colors, size_t vertex_colors_stride, size_t vertex_count, const unsigned int* vertex_cells) +{ + static const float dummy_color[] = {0.f, 0.f, 0.f}; + + size_t vertex_colors_stride_float = vertex_colors_stride / sizeof(float); + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int cell = vertex_cells[i]; + const Vector3& v = vertex_positions[i]; + Reservoir& r = cell_reservoirs[cell]; + + const float* color = vertex_colors ? &vertex_colors[i * vertex_colors_stride_float] : dummy_color; + + r.x += v.x; + r.y += v.y; + r.z += v.z; + r.r += color[0]; + r.g += color[1]; + r.b += color[2]; + r.w += 1.f; + } + + for (size_t i = 0; i < cell_count; ++i) + { + Reservoir& r = cell_reservoirs[i]; + + float iw = r.w == 0.f ? 0.f : 1.f / r.w; + + r.x *= iw; + r.y *= iw; + r.z *= iw; + r.r *= iw; + r.g *= iw; + r.b *= iw; + } +} + +static void fillCellRemap(unsigned int* cell_remap, float* cell_errors, size_t cell_count, const unsigned int* vertex_cells, const Quadric* cell_quadrics, const Vector3* vertex_positions, size_t vertex_count) +{ + memset(cell_remap, -1, cell_count * sizeof(unsigned int)); + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int cell = vertex_cells[i]; + float error = quadricError(cell_quadrics[cell], vertex_positions[i]); + + if (cell_remap[cell] == ~0u || cell_errors[cell] > error) + { + cell_remap[cell] = unsigned(i); + cell_errors[cell] = error; + } + } +} + +static void fillCellRemap(unsigned int* cell_remap, float* cell_errors, size_t cell_count, const unsigned int* vertex_cells, const Reservoir* cell_reservoirs, const Vector3* vertex_positions, const float* vertex_colors, size_t vertex_colors_stride, float color_weight, size_t vertex_count) +{ + static const float dummy_color[] = {0.f, 0.f, 0.f}; + + size_t vertex_colors_stride_float = vertex_colors_stride / sizeof(float); + + memset(cell_remap, -1, cell_count * sizeof(unsigned int)); + + for (size_t i = 0; i < vertex_count; ++i) + { + unsigned int cell = vertex_cells[i]; + const Vector3& v = vertex_positions[i]; + const Reservoir& r = cell_reservoirs[cell]; + + const float* color = vertex_colors ? &vertex_colors[i * vertex_colors_stride_float] : dummy_color; + + float pos_error = (v.x - r.x) * (v.x - r.x) + (v.y - r.y) * (v.y - r.y) + (v.z - r.z) * (v.z - r.z); + float col_error = (color[0] - r.r) * (color[0] - r.r) + (color[1] - r.g) * (color[1] - r.g) + (color[2] - r.b) * (color[2] - r.b); + float error = pos_error + color_weight * col_error; + + if (cell_remap[cell] == ~0u || cell_errors[cell] > error) + { + cell_remap[cell] = unsigned(i); + cell_errors[cell] = error; + } + } +} + +static size_t filterTriangles(unsigned int* destination, unsigned int* tritable, size_t tritable_size, const unsigned int* indices, size_t index_count, const unsigned int* vertex_cells, const unsigned int* cell_remap) +{ + TriangleHasher hasher = {destination}; + + memset(tritable, -1, tritable_size * sizeof(unsigned int)); + + size_t result = 0; + + for (size_t i = 0; i < index_count; i += 3) + { + unsigned int c0 = vertex_cells[indices[i + 0]]; + unsigned int c1 = vertex_cells[indices[i + 1]]; + unsigned int c2 = vertex_cells[indices[i + 2]]; + + if (c0 != c1 && c0 != c2 && c1 != c2) + { + unsigned int a = cell_remap[c0]; + unsigned int b = cell_remap[c1]; + unsigned int c = cell_remap[c2]; + + if (b < a && b < c) + { + unsigned int t = a; + a = b, b = c, c = t; + } + else if (c < a && c < b) + { + unsigned int t = c; + c = b, b = a, a = t; + } + + destination[result * 3 + 0] = a; + destination[result * 3 + 1] = b; + destination[result * 3 + 2] = c; + + unsigned int* entry = hashLookup2(tritable, tritable_size, hasher, unsigned(result), ~0u); + + if (*entry == ~0u) + *entry = unsigned(result++); + } + } + + return result * 3; +} + +static float interpolate(float y, float x0, float y0, float x1, float y1, float x2, float y2) +{ + // three point interpolation from "revenge of interpolation search" paper + float num = (y1 - y) * (x1 - x2) * (x1 - x0) * (y2 - y0); + float den = (y2 - y) * (x1 - x2) * (y0 - y1) + (y0 - y) * (x1 - x0) * (y1 - y2); + return x1 + (den == 0.f ? 0.f : num / den); +} + +} // namespace meshopt + +// Note: this is only exposed for development purposes; do *not* use +enum +{ + meshopt_SimplifyInternalSolve = 1 << 29, + meshopt_SimplifyInternalDebug = 1 << 30 +}; + +size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_attributes_data, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options, float* out_result_error) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + assert(target_index_count <= index_count); + assert(target_error >= 0); + assert(vertex_attributes_stride >= attribute_count * sizeof(float) && vertex_attributes_stride <= 256); + assert(vertex_attributes_stride % sizeof(float) == 0); + assert(attribute_count <= kMaxAttributes); + for (size_t i = 0; i < attribute_count; ++i) + assert(attribute_weights[i] >= 0); + + meshopt_Allocator allocator; + + unsigned int* result = destination; + if (result != indices) + memcpy(result, indices, index_count * sizeof(unsigned int)); + + // build an index remap and update indices/vertex_count to minimize the subsequent work + // note: as a consequence, errors will be computed relative to the subset extent + unsigned int* sparse_remap = NULL; + if (options & meshopt_SimplifySparse) + sparse_remap = buildSparseRemap(result, index_count, vertex_count, &vertex_count, allocator); + + // build adjacency information + EdgeAdjacency adjacency = {}; + prepareEdgeAdjacency(adjacency, index_count, vertex_count, allocator); + updateEdgeAdjacency(adjacency, result, index_count, vertex_count, NULL); + + // build position remap that maps each vertex to the one with identical position + // wedge table stores next vertex with identical position for each vertex + unsigned int* remap = allocator.allocate(vertex_count); + unsigned int* wedge = allocator.allocate(vertex_count); + buildPositionRemap(remap, wedge, vertex_positions_data, vertex_count, vertex_positions_stride, sparse_remap, allocator); + + // classify vertices; vertex kind determines collapse rules, see kCanCollapse + unsigned char* vertex_kind = allocator.allocate(vertex_count); + unsigned int* loop = allocator.allocate(vertex_count); + unsigned int* loopback = allocator.allocate(vertex_count); + classifyVertices(vertex_kind, loop, loopback, vertex_count, adjacency, remap, wedge, vertex_lock, sparse_remap, options); + +#if TRACE + size_t unique_positions = 0; + for (size_t i = 0; i < vertex_count; ++i) + unique_positions += remap[i] == i; + + printf("position remap: %d vertices => %d positions\n", int(vertex_count), int(unique_positions)); + + size_t kinds[Kind_Count] = {}; + for (size_t i = 0; i < vertex_count; ++i) + kinds[vertex_kind[i]] += remap[i] == i; + + printf("kinds: manifold %d, border %d, seam %d, complex %d, fringe %d, locked %d\n", + int(kinds[Kind_Manifold]), int(kinds[Kind_Border]), int(kinds[Kind_Seam]), int(kinds[Kind_Complex]), int(kinds[Kind_Fringe]), int(kinds[Kind_Locked])); +#endif + + Vector3* vertex_positions = allocator.allocate(vertex_count); + float vertex_offset[3] = {}; + float vertex_scale = rescalePositions(vertex_positions, vertex_positions_data, vertex_count, vertex_positions_stride, sparse_remap, vertex_offset); + + float* vertex_attributes = NULL; + unsigned int attribute_remap[kMaxAttributes]; + + if (attribute_count) + { + // remap attributes to only include ones with weight > 0 to minimize memory/compute overhead for quadrics + size_t attributes_used = 0; + for (size_t i = 0; i < attribute_count; ++i) + if (attribute_weights[i] > 0) + attribute_remap[attributes_used++] = unsigned(i); + + attribute_count = attributes_used; + vertex_attributes = allocator.allocate(vertex_count * attribute_count); + rescaleAttributes(vertex_attributes, vertex_attributes_data, vertex_count, vertex_attributes_stride, attribute_weights, attribute_count, attribute_remap, sparse_remap); + } + + Quadric* vertex_quadrics = allocator.allocate(vertex_count); + memset(vertex_quadrics, 0, vertex_count * sizeof(Quadric)); + + Quadric* attribute_quadrics = NULL; + QuadricGrad* attribute_gradients = NULL; + QuadricGrad* volume_gradients = NULL; + + if (attribute_count) + { + attribute_quadrics = allocator.allocate(vertex_count); + memset(attribute_quadrics, 0, vertex_count * sizeof(Quadric)); + + attribute_gradients = allocator.allocate(vertex_count * attribute_count); + memset(attribute_gradients, 0, vertex_count * attribute_count * sizeof(QuadricGrad)); + + if (options & meshopt_SimplifyInternalSolve) + { + volume_gradients = allocator.allocate(vertex_count); + memset(volume_gradients, 0, vertex_count * sizeof(QuadricGrad)); + } + } + + fillFaceQuadrics(vertex_quadrics, volume_gradients, result, index_count, vertex_positions, remap); + fillVertexQuadrics(vertex_quadrics, vertex_positions, vertex_count, remap, vertex_lock, sparse_remap, options); + fillEdgeQuadrics(vertex_quadrics, result, index_count, vertex_positions, remap, vertex_kind, loop, loopback); + + if (options & meshopt_SimplifyPreserveFolds) + fillFoldQuadrics(vertex_quadrics, adjacency, vertex_positions, vertex_count, remap, wedge); + + if (attribute_count) + fillAttributeQuadrics(attribute_quadrics, attribute_gradients, result, index_count, vertex_positions, vertex_attributes, attribute_count); + + unsigned int* components = NULL; + float* component_errors = NULL; + size_t component_count = 0; + float component_nexterror = 0; + + if (options & meshopt_SimplifyPrune) + { + components = allocator.allocate(vertex_count); + component_count = buildComponents(components, vertex_count, result, index_count, remap); + + component_errors = allocator.allocate(component_count * 4); // overallocate for temporary use inside measureComponents + measureComponents(component_errors, component_count, components, vertex_positions, vertex_count); + + component_nexterror = FLT_MAX; + for (size_t i = 0; i < component_count; ++i) + component_nexterror = component_nexterror > component_errors[i] ? component_errors[i] : component_nexterror; + +#if TRACE + printf("components: %d (min error %e)\n", int(component_count), sqrtf(component_nexterror)); +#endif + } + +#if TRACE + size_t pass_count = 0; +#endif + + size_t collapse_capacity = boundEdgeCollapses(adjacency, vertex_count, index_count, vertex_kind); + + Collapse* edge_collapses = allocator.allocate(collapse_capacity); + unsigned int* collapse_order = allocator.allocate(collapse_capacity); + unsigned int* collapse_remap = allocator.allocate(vertex_count); + unsigned char* collapse_locked = allocator.allocate(vertex_count); + + size_t result_count = index_count; + float result_error = 0; + float vertex_error = 0; + + // target_error input is linear; we need to adjust it to match quadricError units + float error_scale = (options & meshopt_SimplifyErrorAbsolute) ? vertex_scale : 1.f; + float error_limit = (target_error * target_error) / (error_scale * error_scale); + + while (result_count > target_index_count) + { + // note: throughout the simplification process adjacency structure reflects welded topology for result-in-progress + updateEdgeAdjacency(adjacency, result, result_count, vertex_count, remap); + + size_t edge_collapse_count = pickEdgeCollapses(edge_collapses, collapse_capacity, result, result_count, remap, vertex_kind, loop, loopback); + assert(edge_collapse_count <= collapse_capacity); + + // no edges can be collapsed any more due to topology restrictions + if (edge_collapse_count == 0) + break; + +#if TRACE + printf("pass %d: ", int(pass_count++)); +#endif + + rankEdgeCollapses(edge_collapses, edge_collapse_count, vertex_positions, vertex_attributes, vertex_quadrics, attribute_quadrics, attribute_gradients, attribute_count, remap, wedge, vertex_kind, loop, loopback, (options & meshopt_SimplifyErrorClamped) != 0); + + sortEdgeCollapses(collapse_order, edge_collapses, edge_collapse_count); + + size_t triangle_collapse_goal = (result_count - target_index_count) / 3; + + for (size_t i = 0; i < vertex_count; ++i) + collapse_remap[i] = unsigned(i); + + memset(collapse_locked, 0, vertex_count); + + size_t collapses = performEdgeCollapses(collapse_remap, collapse_locked, edge_collapses, edge_collapse_count, collapse_order, remap, wedge, vertex_kind, loop, loopback, vertex_positions, adjacency, triangle_collapse_goal, error_limit, result_error); + + // no edges can be collapsed any more due to hitting the error limit or triangle collapse limit + if (collapses == 0) + break; + + updateQuadrics(collapse_remap, vertex_count, vertex_quadrics, volume_gradients, attribute_quadrics, attribute_gradients, attribute_count, vertex_positions, remap, vertex_error); + + // updateQuadrics will update vertex error if we use attributes, but if we don't then result_error and vertex_error are equivalent + vertex_error = attribute_count == 0 ? result_error : vertex_error; + + // note: we update loops following edge collapses, but after this we might still have stale loop data + // this can happen when a triangle with a loop edge gets collapsed along a non-loop edge + // that works since a loop that points to a vertex that is no longer connected is not affecting collapse logic + remapEdgeLoops(loop, vertex_count, collapse_remap); + remapEdgeLoops(loopback, vertex_count, collapse_remap); + + result_count = remapIndexBuffer(result, result_count, collapse_remap, remap); + + if ((options & meshopt_SimplifyPrune) && result_count > target_index_count && component_nexterror <= vertex_error) + result_count = pruneComponents(result, result_count, components, component_errors, component_count, vertex_error, component_nexterror); + } + + // at this point, component_nexterror might be stale: component it references may have been removed through a series of edge collapses + bool component_nextstale = true; + + // we're done with the regular simplification but we're still short of the target; try pruning more aggressively towards error_limit + while ((options & meshopt_SimplifyPrune) && result_count > target_index_count && component_nexterror <= error_limit) + { +#if TRACE + printf("pass %d: cleanup; ", int(pass_count++)); +#endif + + float component_cutoff = component_nexterror * 1.5f < error_limit ? component_nexterror * 1.5f : error_limit; + + // track maximum error in eligible components as we are increasing resulting error + float component_maxerror = 0; + for (size_t i = 0; i < component_count; ++i) + if (component_errors[i] > component_maxerror && component_errors[i] <= component_cutoff) + component_maxerror = component_errors[i]; + + size_t new_count = pruneComponents(result, result_count, components, component_errors, component_count, component_cutoff, component_nexterror); + if (new_count == result_count && !component_nextstale) + break; + + component_nextstale = false; // pruneComponents guarantees next error is up to date + result_count = new_count; + result_error = result_error < component_maxerror ? component_maxerror : result_error; + vertex_error = vertex_error < component_maxerror ? component_maxerror : vertex_error; + } + +#if TRACE + printf("result: %d triangles, error: %e (pos %.3e); total %d passes\n", int(result_count / 3), sqrtf(result_error), sqrtf(vertex_error), int(pass_count)); +#endif + + // if solve is requested, update input buffers destructively from internal data + if (options & meshopt_SimplifyInternalSolve) + { + unsigned char* vertex_update = collapse_locked; // reuse as scratch space + memset(vertex_update, 0, vertex_count); + + // limit quadric solve to vertices that are still used in the result + for (size_t i = 0; i < result_count; ++i) + { + unsigned int v = result[i]; + + // mark the vertex for finalizeVertices and root vertex for solve* + vertex_update[remap[v]] = vertex_update[v] = 1; + } + + // edge adjacency may be stale as we haven't updated it after last series of edge collapses + updateEdgeAdjacency(adjacency, result, result_count, vertex_count, remap); + + solvePositions(vertex_positions, vertex_count, vertex_quadrics, volume_gradients, attribute_quadrics, attribute_gradients, attribute_count, remap, wedge, adjacency, vertex_kind, vertex_update); + + if (attribute_count) + solveAttributes(vertex_positions, vertex_attributes, vertex_count, attribute_quadrics, attribute_gradients, attribute_count, remap, wedge, vertex_kind, vertex_update); + + finalizeVertices(const_cast(vertex_positions_data), vertex_positions_stride, const_cast(vertex_attributes_data), vertex_attributes_stride, attribute_weights, attribute_count, vertex_count, vertex_positions, vertex_attributes, sparse_remap, attribute_remap, vertex_scale, vertex_offset, vertex_kind, vertex_update, vertex_lock); + } + + // if debug visualization data is requested, fill it instead of index data; for simplicity, this doesn't work with sparsity + if ((options & meshopt_SimplifyInternalDebug) && !sparse_remap) + { + assert(Kind_Count <= 8 && vertex_count < (1 << 28)); // 3 bit kind, 1 bit loop + + for (size_t i = 0; i < result_count; i += 3) + { + unsigned int a = result[i + 0], b = result[i + 1], c = result[i + 2]; + + result[i + 0] |= (vertex_kind[a] << 28) | (unsigned(loop[a] == b || loopback[b] == a) << 31); + result[i + 1] |= (vertex_kind[b] << 28) | (unsigned(loop[b] == c || loopback[c] == b) << 31); + result[i + 2] |= (vertex_kind[c] << 28) | (unsigned(loop[c] == a || loopback[a] == c) << 31); + } + } + + // convert resulting indices back into the dense space of the larger mesh + if (sparse_remap) + for (size_t i = 0; i < result_count; ++i) + result[i] = sparse_remap[result[i]]; + + // result_error is quadratic; we need to remap it back to linear + if (out_result_error) + *out_result_error = sqrtf(result_error) * error_scale; + + return result_count; +} + +size_t meshopt_simplify(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, size_t target_index_count, float target_error, unsigned int options, float* out_result_error) +{ + assert((options & meshopt_SimplifyInternalSolve) == 0); // use meshopt_simplifyWithUpdate instead + + return meshopt_simplifyEdge(destination, indices, index_count, vertex_positions_data, vertex_count, vertex_positions_stride, NULL, 0, NULL, 0, NULL, target_index_count, target_error, options, out_result_error); +} + +size_t meshopt_simplifyWithAttributes(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_attributes_data, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options, float* out_result_error) +{ + assert((options & meshopt_SimplifyInternalSolve) == 0); // use meshopt_simplifyWithUpdate instead + + return meshopt_simplifyEdge(destination, indices, index_count, vertex_positions_data, vertex_count, vertex_positions_stride, vertex_attributes_data, vertex_attributes_stride, attribute_weights, attribute_count, vertex_lock, target_index_count, target_error, options, out_result_error); +} + +size_t meshopt_simplifyWithUpdate(unsigned int* indices, size_t index_count, float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, float* vertex_attributes_data, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned char* vertex_lock, size_t target_index_count, float target_error, unsigned int options, float* out_result_error) +{ + return meshopt_simplifyEdge(indices, indices, index_count, vertex_positions_data, vertex_count, vertex_positions_stride, vertex_attributes_data, vertex_attributes_stride, attribute_weights, attribute_count, vertex_lock, target_index_count, target_error, options | meshopt_SimplifyInternalSolve, out_result_error); +} + +size_t meshopt_simplifySloppy(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, const unsigned char* vertex_lock, size_t target_index_count, float target_error, float* out_result_error) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + assert(target_index_count <= index_count); + assert(target_error >= 0); + + // we expect to get ~2 triangles/vertex in the output + size_t target_cell_count = target_index_count / 6; + + meshopt_Allocator allocator; + + Vector3* vertex_positions = allocator.allocate(vertex_count); + rescalePositions(vertex_positions, vertex_positions_data, vertex_count, vertex_positions_stride); + + // find the optimal grid size using guided binary search +#if TRACE + printf("source: %d vertices, %d triangles\n", int(vertex_count), int(index_count / 3)); + printf("target: %d cells, %d triangles\n", int(target_cell_count), int(target_index_count / 3)); +#endif + + unsigned int* vertex_ids = allocator.allocate(vertex_count); + + const int kInterpolationPasses = 5; + + // invariant: # of triangles in min_grid <= target_count + int min_grid = int(1.f / (target_error < 1e-3f ? 1e-3f : (target_error < 1.f ? target_error : 1.f))); + int max_grid = 1025; + size_t min_triangles = 0; + size_t max_triangles = index_count / 3; + + // when we're error-limited, we compute the triangle count for the min. size; this accelerates convergence and provides the correct answer when we can't use a larger grid + if (min_grid > 1 || vertex_lock) + { + computeVertexIds(vertex_ids, vertex_positions, vertex_lock, vertex_count, min_grid); + min_triangles = countTriangles(vertex_ids, indices, index_count); + } + + // instead of starting in the middle, let's guess as to what the answer might be! triangle count usually grows as a square of grid size... + int next_grid_size = int(sqrtf(float(target_cell_count)) + 0.5f); + + for (int pass = 0; pass < 10 + kInterpolationPasses; ++pass) + { + if (min_triangles >= target_index_count / 3 || max_grid - min_grid <= 1) + break; + + // we clamp the prediction of the grid size to make sure that the search converges + int grid_size = next_grid_size; + grid_size = (grid_size <= min_grid) ? min_grid + 1 : (grid_size >= max_grid ? max_grid - 1 : grid_size); + + computeVertexIds(vertex_ids, vertex_positions, vertex_lock, vertex_count, grid_size); + size_t triangles = countTriangles(vertex_ids, indices, index_count); + +#if TRACE + printf("pass %d (%s): grid size %d, triangles %d, %s\n", + pass, (pass == 0) ? "guess" : (pass <= kInterpolationPasses ? "lerp" : "binary"), + grid_size, int(triangles), + (triangles <= target_index_count / 3) ? "under" : "over"); +#endif + + float tip = interpolate(float(size_t(target_index_count / 3)), float(min_grid), float(min_triangles), float(grid_size), float(triangles), float(max_grid), float(max_triangles)); + + if (triangles <= target_index_count / 3) + { + min_grid = grid_size; + min_triangles = triangles; + } + else + { + max_grid = grid_size; + max_triangles = triangles; + } + + // we start by using interpolation search - it usually converges faster + // however, interpolation search has a worst case of O(N) so we switch to binary search after a few iterations which converges in O(logN) + next_grid_size = (pass < kInterpolationPasses) ? int(tip + 0.5f) : (min_grid + max_grid) / 2; + } + + if (min_triangles == 0) + { + if (out_result_error) + *out_result_error = 1.f; + + return 0; + } + + // build vertex->cell association by mapping all vertices with the same quantized position to the same cell + size_t table_size = hashBuckets2(vertex_count); + unsigned int* table = allocator.allocate(table_size); + + unsigned int* vertex_cells = allocator.allocate(vertex_count); + + computeVertexIds(vertex_ids, vertex_positions, vertex_lock, vertex_count, min_grid); + size_t cell_count = fillVertexCells(table, table_size, vertex_cells, vertex_ids, vertex_count); + + // build a quadric for each target cell + Quadric* cell_quadrics = allocator.allocate(cell_count); + memset(cell_quadrics, 0, cell_count * sizeof(Quadric)); + + fillCellQuadrics(cell_quadrics, indices, index_count, vertex_positions, vertex_cells); + + // for each target cell, find the vertex with the minimal error + unsigned int* cell_remap = allocator.allocate(cell_count); + float* cell_errors = allocator.allocate(cell_count); + + fillCellRemap(cell_remap, cell_errors, cell_count, vertex_cells, cell_quadrics, vertex_positions, vertex_count); + + // compute error + float result_error = 0.f; + + for (size_t i = 0; i < cell_count; ++i) + result_error = result_error < cell_errors[i] ? cell_errors[i] : result_error; + + // vertex collapses often result in duplicate triangles; we need a table to filter them out + size_t tritable_size = hashBuckets2(min_triangles); + unsigned int* tritable = allocator.allocate(tritable_size); + + // note: this is the first and last write to destination, which allows aliasing destination with indices + size_t write = filterTriangles(destination, tritable, tritable_size, indices, index_count, vertex_cells, cell_remap); + +#if TRACE + printf("result: grid size %d, %d cells, %d triangles (%d unfiltered), error %e\n", min_grid, int(cell_count), int(write / 3), int(min_triangles), sqrtf(result_error)); +#endif + + if (out_result_error) + *out_result_error = sqrtf(result_error); + + return write; +} + +size_t meshopt_simplifyPrune(unsigned int* destination, const unsigned int* indices, size_t index_count, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, float target_error) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + assert(target_error >= 0); + + meshopt_Allocator allocator; + + unsigned int* result = destination; + if (result != indices) + memcpy(result, indices, index_count * sizeof(unsigned int)); + + // build position remap that maps each vertex to the one with identical position + unsigned int* remap = allocator.allocate(vertex_count); + buildPositionRemap(remap, NULL, vertex_positions_data, vertex_count, vertex_positions_stride, NULL, allocator); + + Vector3* vertex_positions = allocator.allocate(vertex_count); + rescalePositions(vertex_positions, vertex_positions_data, vertex_count, vertex_positions_stride, NULL); + + unsigned int* components = allocator.allocate(vertex_count); + size_t component_count = buildComponents(components, vertex_count, indices, index_count, remap); + + float* component_errors = allocator.allocate(component_count * 4); // overallocate for temporary use inside measureComponents + measureComponents(component_errors, component_count, components, vertex_positions, vertex_count); + + float component_nexterror = 0; + size_t result_count = pruneComponents(result, index_count, components, component_errors, component_count, target_error * target_error, component_nexterror); + + return result_count; +} + +size_t meshopt_simplifyPoints(unsigned int* destination, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, const float* vertex_colors, size_t vertex_colors_stride, float color_weight, size_t target_vertex_count) +{ + using namespace meshopt; + + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + assert(vertex_colors_stride == 0 || (vertex_colors_stride >= 12 && vertex_colors_stride <= 256)); + assert(vertex_colors_stride % sizeof(float) == 0); + assert(vertex_colors == NULL || vertex_colors_stride != 0); + assert(target_vertex_count <= vertex_count); + + size_t target_cell_count = target_vertex_count; + + if (target_cell_count == 0) + return 0; + + meshopt_Allocator allocator; + + Vector3* vertex_positions = allocator.allocate(vertex_count); + rescalePositions(vertex_positions, vertex_positions_data, vertex_count, vertex_positions_stride); + + // find the optimal grid size using guided binary search +#if TRACE + printf("source: %d vertices\n", int(vertex_count)); + printf("target: %d cells\n", int(target_cell_count)); +#endif + + unsigned int* vertex_ids = allocator.allocate(vertex_count); + + size_t table_size = hashBuckets2(vertex_count); + unsigned int* table = allocator.allocate(table_size); + + const int kInterpolationPasses = 5; + + // invariant: # of vertices in min_grid <= target_count + int min_grid = 0; + int max_grid = 1025; + size_t min_vertices = 0; + size_t max_vertices = vertex_count; + + // instead of starting in the middle, let's guess as to what the answer might be! surface point count usually grows as a square of grid size... + int next_grid_size = int(sqrtf(float(target_cell_count)) + 0.5f); + + for (int pass = 0; pass < 10 + kInterpolationPasses; ++pass) + { + assert(min_vertices < target_vertex_count); + assert(max_grid - min_grid > 1); + + // we clamp the prediction of the grid size to make sure that the search converges + int grid_size = next_grid_size; + grid_size = (grid_size <= min_grid) ? min_grid + 1 : (grid_size >= max_grid ? max_grid - 1 : grid_size); + + computeVertexIds(vertex_ids, vertex_positions, NULL, vertex_count, grid_size); + size_t vertices = countVertexCells(table, table_size, vertex_ids, vertex_count); + +#if TRACE + printf("pass %d (%s): grid size %d, vertices %d, %s\n", + pass, (pass == 0) ? "guess" : (pass <= kInterpolationPasses ? "lerp" : "binary"), + grid_size, int(vertices), + (vertices <= target_vertex_count) ? "under" : "over"); +#endif + + float tip = interpolate(float(target_vertex_count), float(min_grid), float(min_vertices), float(grid_size), float(vertices), float(max_grid), float(max_vertices)); + + if (vertices <= target_vertex_count) + { + min_grid = grid_size; + min_vertices = vertices; + } + else + { + max_grid = grid_size; + max_vertices = vertices; + } + + if (vertices == target_vertex_count || max_grid - min_grid <= 1) + break; + + // we start by using interpolation search - it usually converges faster + // however, interpolation search has a worst case of O(N) so we switch to binary search after a few iterations which converges in O(logN) + next_grid_size = (pass < kInterpolationPasses) ? int(tip + 0.5f) : (min_grid + max_grid) / 2; + } + + if (min_vertices == 0) + return 0; + + // build vertex->cell association by mapping all vertices with the same quantized position to the same cell + unsigned int* vertex_cells = allocator.allocate(vertex_count); + + computeVertexIds(vertex_ids, vertex_positions, NULL, vertex_count, min_grid); + size_t cell_count = fillVertexCells(table, table_size, vertex_cells, vertex_ids, vertex_count); + + // accumulate points into a reservoir for each target cell + Reservoir* cell_reservoirs = allocator.allocate(cell_count); + memset(cell_reservoirs, 0, cell_count * sizeof(Reservoir)); + + fillCellReservoirs(cell_reservoirs, cell_count, vertex_positions, vertex_colors, vertex_colors_stride, vertex_count, vertex_cells); + + // for each target cell, find the vertex with the minimal error + unsigned int* cell_remap = allocator.allocate(cell_count); + float* cell_errors = allocator.allocate(cell_count); + + // we scale the color weight to bring it to the same scale as position so that error addition makes sense + float color_weight_scaled = color_weight * (min_grid == 1 ? 1.f : 1.f / (min_grid - 1)); + + fillCellRemap(cell_remap, cell_errors, cell_count, vertex_cells, cell_reservoirs, vertex_positions, vertex_colors, vertex_colors_stride, color_weight_scaled * color_weight_scaled, vertex_count); + + // copy results to the output + assert(cell_count <= target_vertex_count); + memcpy(destination, cell_remap, sizeof(unsigned int) * cell_count); + +#if TRACE + // compute error + float result_error = 0.f; + + for (size_t i = 0; i < cell_count; ++i) + result_error = result_error < cell_errors[i] ? cell_errors[i] : result_error; + + printf("result: %d cells, %e error\n", int(cell_count), sqrtf(result_error)); +#endif + + return cell_count; +} + +float meshopt_simplifyScale(const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride) +{ + using namespace meshopt; + + assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); + assert(vertex_positions_stride % sizeof(float) == 0); + + float extent = rescalePositions(NULL, vertex_positions, vertex_count, vertex_positions_stride); + + return extent; +} diff --git a/Sources/CMeshOptimizer/vcacheoptimizer.cpp b/Sources/CMeshOptimizer/vcacheoptimizer.cpp new file mode 100644 index 000000000..e4ecc71d3 --- /dev/null +++ b/Sources/CMeshOptimizer/vcacheoptimizer.cpp @@ -0,0 +1,467 @@ +// This file is part of meshoptimizer library; see meshoptimizer.h for version/license details +#include "meshoptimizer.h" + +#include +#include + +// This work is based on: +// Tom Forsyth. Linear-Speed Vertex Cache Optimisation. 2006 +// Pedro Sander, Diego Nehab and Joshua Barczak. Fast Triangle Reordering for Vertex Locality and Reduced Overdraw. 2007 +namespace meshopt +{ + +const size_t kCacheSizeMax = 16; +const size_t kValenceMax = 8; + +struct VertexScoreTable +{ + float cache[1 + kCacheSizeMax]; + float live[1 + kValenceMax]; +}; + +// Tuned to minimize the ACMR of a GPU that has a cache profile similar to NVidia and AMD +static const VertexScoreTable kVertexScoreTable = { + {0.f, 0.779f, 0.791f, 0.789f, 0.981f, 0.843f, 0.726f, 0.847f, 0.882f, 0.867f, 0.799f, 0.642f, 0.613f, 0.600f, 0.568f, 0.372f, 0.234f}, + {0.f, 0.995f, 0.713f, 0.450f, 0.404f, 0.059f, 0.005f, 0.147f, 0.006f}, +}; + +// Tuned to minimize the encoded index buffer size +static const VertexScoreTable kVertexScoreTableStrip = { + {0.f, 1.000f, 1.000f, 1.000f, 0.453f, 0.561f, 0.490f, 0.459f, 0.179f, 0.526f, 0.000f, 0.227f, 0.184f, 0.490f, 0.112f, 0.050f, 0.131f}, + {0.f, 0.956f, 0.786f, 0.577f, 0.558f, 0.618f, 0.549f, 0.499f, 0.489f}, +}; + +struct TriangleAdjacency +{ + unsigned int* counts; + unsigned int* offsets; + unsigned int* data; +}; + +static void buildTriangleAdjacency(TriangleAdjacency& adjacency, const unsigned int* indices, size_t index_count, size_t vertex_count, meshopt_Allocator& allocator) +{ + size_t face_count = index_count / 3; + + // allocate arrays + adjacency.counts = allocator.allocate(vertex_count); + adjacency.offsets = allocator.allocate(vertex_count); + adjacency.data = allocator.allocate(index_count); + + // fill triangle counts + memset(adjacency.counts, 0, vertex_count * sizeof(unsigned int)); + + for (size_t i = 0; i < index_count; ++i) + { + assert(indices[i] < vertex_count); + + adjacency.counts[indices[i]]++; + } + + // fill offset table + unsigned int offset = 0; + + for (size_t i = 0; i < vertex_count; ++i) + { + adjacency.offsets[i] = offset; + offset += adjacency.counts[i]; + } + + assert(offset == index_count); + + // fill triangle data + for (size_t i = 0; i < face_count; ++i) + { + unsigned int a = indices[i * 3 + 0], b = indices[i * 3 + 1], c = indices[i * 3 + 2]; + + adjacency.data[adjacency.offsets[a]++] = unsigned(i); + adjacency.data[adjacency.offsets[b]++] = unsigned(i); + adjacency.data[adjacency.offsets[c]++] = unsigned(i); + } + + // fix offsets that have been disturbed by the previous pass + for (size_t i = 0; i < vertex_count; ++i) + { + assert(adjacency.offsets[i] >= adjacency.counts[i]); + + adjacency.offsets[i] -= adjacency.counts[i]; + } +} + +static unsigned int getNextVertexDeadEnd(const unsigned int* dead_end, unsigned int& dead_end_top, unsigned int& input_cursor, const unsigned int* live_triangles, size_t vertex_count) +{ + // check dead-end stack + while (dead_end_top) + { + unsigned int vertex = dead_end[--dead_end_top]; + + if (live_triangles[vertex] > 0) + return vertex; + } + + // input order + while (input_cursor < vertex_count) + { + if (live_triangles[input_cursor] > 0) + return input_cursor; + + ++input_cursor; + } + + return ~0u; +} + +static unsigned int getNextVertexNeighbor(const unsigned int* next_candidates_begin, const unsigned int* next_candidates_end, const unsigned int* live_triangles, const unsigned int* cache_timestamps, unsigned int timestamp, unsigned int cache_size) +{ + unsigned int best_candidate = ~0u; + int best_priority = -1; + + for (const unsigned int* next_candidate = next_candidates_begin; next_candidate != next_candidates_end; ++next_candidate) + { + unsigned int vertex = *next_candidate; + + // otherwise we don't need to process it + if (live_triangles[vertex] > 0) + { + int priority = 0; + + // will it be in cache after fanning? + if (2 * live_triangles[vertex] + timestamp - cache_timestamps[vertex] <= cache_size) + { + priority = timestamp - cache_timestamps[vertex]; // position in cache + } + + if (priority > best_priority) + { + best_candidate = vertex; + best_priority = priority; + } + } + } + + return best_candidate; +} + +static float vertexScore(const VertexScoreTable* table, int cache_position, unsigned int live_triangles) +{ + assert(cache_position >= -1 && cache_position < int(kCacheSizeMax)); + + unsigned int live_triangles_clamped = live_triangles < kValenceMax ? live_triangles : kValenceMax; + + return table->cache[1 + cache_position] + table->live[live_triangles_clamped]; +} + +static unsigned int getNextTriangleDeadEnd(unsigned int& input_cursor, const unsigned char* emitted_flags, size_t face_count) +{ + // input order + while (input_cursor < face_count) + { + if (!emitted_flags[input_cursor]) + return input_cursor; + + ++input_cursor; + } + + return ~0u; +} + +} // namespace meshopt + +void meshopt_optimizeVertexCacheTable(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, const meshopt::VertexScoreTable* table) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + + meshopt_Allocator allocator; + + // guard for empty meshes + if (index_count == 0 || vertex_count == 0) + return; + + // support in-place optimization + if (destination == indices) + { + unsigned int* indices_copy = allocator.allocate(index_count); + memcpy(indices_copy, indices, index_count * sizeof(unsigned int)); + indices = indices_copy; + } + + unsigned int cache_size = 16; + assert(cache_size <= kCacheSizeMax); + + size_t face_count = index_count / 3; + + // build adjacency information + TriangleAdjacency adjacency = {}; + buildTriangleAdjacency(adjacency, indices, index_count, vertex_count, allocator); + + // live triangle counts; note, we alias adjacency.counts as we remove triangles after emitting them so the counts always match + unsigned int* live_triangles = adjacency.counts; + + // emitted flags + unsigned char* emitted_flags = allocator.allocate(face_count); + memset(emitted_flags, 0, face_count); + + // compute initial vertex scores + float* vertex_scores = allocator.allocate(vertex_count); + + for (size_t i = 0; i < vertex_count; ++i) + vertex_scores[i] = vertexScore(table, -1, live_triangles[i]); + + // compute triangle scores + float* triangle_scores = allocator.allocate(face_count); + + for (size_t i = 0; i < face_count; ++i) + { + unsigned int a = indices[i * 3 + 0]; + unsigned int b = indices[i * 3 + 1]; + unsigned int c = indices[i * 3 + 2]; + + triangle_scores[i] = vertex_scores[a] + vertex_scores[b] + vertex_scores[c]; + } + + unsigned int cache_holder[2 * (kCacheSizeMax + 4)]; + unsigned int* cache = cache_holder; + unsigned int* cache_new = cache_holder + kCacheSizeMax + 4; + size_t cache_count = 0; + + unsigned int current_triangle = 0; + unsigned int input_cursor = 1; + + unsigned int output_triangle = 0; + + while (current_triangle != ~0u) + { + assert(output_triangle < face_count); + + unsigned int a = indices[current_triangle * 3 + 0]; + unsigned int b = indices[current_triangle * 3 + 1]; + unsigned int c = indices[current_triangle * 3 + 2]; + + // output indices + destination[output_triangle * 3 + 0] = a; + destination[output_triangle * 3 + 1] = b; + destination[output_triangle * 3 + 2] = c; + output_triangle++; + + // update emitted flags + emitted_flags[current_triangle] = true; + triangle_scores[current_triangle] = 0; + + // new triangle + size_t cache_write = 0; + cache_new[cache_write++] = a; + cache_new[cache_write++] = b; + cache_new[cache_write++] = c; + + // old triangles + for (size_t i = 0; i < cache_count; ++i) + { + unsigned int index = cache[i]; + + cache_new[cache_write] = index; + cache_write += (index != a) & (index != b) & (index != c); + } + + unsigned int* cache_temp = cache; + cache = cache_new, cache_new = cache_temp; + cache_count = cache_write > cache_size ? cache_size : cache_write; + + // remove emitted triangle from adjacency data + // this makes sure that we spend less time traversing these lists on subsequent iterations + // live triangle counts are updated as a byproduct of these adjustments + for (size_t k = 0; k < 3; ++k) + { + unsigned int index = indices[current_triangle * 3 + k]; + + unsigned int* neighbors = &adjacency.data[0] + adjacency.offsets[index]; + size_t neighbors_size = adjacency.counts[index]; + + for (size_t i = 0; i < neighbors_size; ++i) + { + unsigned int tri = neighbors[i]; + + if (tri == current_triangle) + { + neighbors[i] = neighbors[neighbors_size - 1]; + adjacency.counts[index]--; + break; + } + } + } + + unsigned int best_triangle = ~0u; + float best_score = 0; + + // update cache positions, vertex scores and triangle scores, and find next best triangle + for (size_t i = 0; i < cache_write; ++i) + { + unsigned int index = cache[i]; + + // no need to update scores if we are never going to use this vertex + if (adjacency.counts[index] == 0) + continue; + + int cache_position = i >= cache_size ? -1 : int(i); + + // update vertex score + float score = vertexScore(table, cache_position, live_triangles[index]); + float score_diff = score - vertex_scores[index]; + + vertex_scores[index] = score; + + // update scores of vertex triangles + const unsigned int* neighbors_begin = &adjacency.data[0] + adjacency.offsets[index]; + const unsigned int* neighbors_end = neighbors_begin + adjacency.counts[index]; + + for (const unsigned int* it = neighbors_begin; it != neighbors_end; ++it) + { + unsigned int tri = *it; + assert(!emitted_flags[tri]); + + float tri_score = triangle_scores[tri] + score_diff; + assert(tri_score > 0); + + best_triangle = best_score < tri_score ? tri : best_triangle; + best_score = best_score < tri_score ? tri_score : best_score; + + triangle_scores[tri] = tri_score; + } + } + + // step through input triangles in order if we hit a dead-end + current_triangle = best_triangle; + + if (current_triangle == ~0u) + { + current_triangle = getNextTriangleDeadEnd(input_cursor, &emitted_flags[0], face_count); + } + } + + assert(input_cursor == face_count); + assert(output_triangle == face_count); +} + +void meshopt_optimizeVertexCache(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count) +{ + meshopt_optimizeVertexCacheTable(destination, indices, index_count, vertex_count, &meshopt::kVertexScoreTable); +} + +void meshopt_optimizeVertexCacheStrip(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count) +{ + meshopt_optimizeVertexCacheTable(destination, indices, index_count, vertex_count, &meshopt::kVertexScoreTableStrip); +} + +void meshopt_optimizeVertexCacheFifo(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count, unsigned int cache_size) +{ + using namespace meshopt; + + assert(index_count % 3 == 0); + assert(cache_size >= 3); + + meshopt_Allocator allocator; + + // guard for empty meshes + if (index_count == 0 || vertex_count == 0) + return; + + // support in-place optimization + if (destination == indices) + { + unsigned int* indices_copy = allocator.allocate(index_count); + memcpy(indices_copy, indices, index_count * sizeof(unsigned int)); + indices = indices_copy; + } + + size_t face_count = index_count / 3; + + // build adjacency information + TriangleAdjacency adjacency = {}; + buildTriangleAdjacency(adjacency, indices, index_count, vertex_count, allocator); + + // live triangle counts + unsigned int* live_triangles = allocator.allocate(vertex_count); + memcpy(live_triangles, adjacency.counts, vertex_count * sizeof(unsigned int)); + + // cache time stamps + unsigned int* cache_timestamps = allocator.allocate(vertex_count); + memset(cache_timestamps, 0, vertex_count * sizeof(unsigned int)); + + // dead-end stack + unsigned int* dead_end = allocator.allocate(index_count); + unsigned int dead_end_top = 0; + + // emitted flags + unsigned char* emitted_flags = allocator.allocate(face_count); + memset(emitted_flags, 0, face_count); + + unsigned int current_vertex = 0; + + unsigned int timestamp = cache_size + 1; + unsigned int input_cursor = 1; // vertex to restart from in case of dead-end + + unsigned int output_triangle = 0; + + while (current_vertex != ~0u) + { + const unsigned int* next_candidates_begin = &dead_end[0] + dead_end_top; + + // emit all vertex neighbors + const unsigned int* neighbors_begin = &adjacency.data[0] + adjacency.offsets[current_vertex]; + const unsigned int* neighbors_end = neighbors_begin + adjacency.counts[current_vertex]; + + for (const unsigned int* it = neighbors_begin; it != neighbors_end; ++it) + { + unsigned int triangle = *it; + + if (!emitted_flags[triangle]) + { + unsigned int a = indices[triangle * 3 + 0], b = indices[triangle * 3 + 1], c = indices[triangle * 3 + 2]; + + // output indices + destination[output_triangle * 3 + 0] = a; + destination[output_triangle * 3 + 1] = b; + destination[output_triangle * 3 + 2] = c; + output_triangle++; + + // update dead-end stack + dead_end[dead_end_top + 0] = a; + dead_end[dead_end_top + 1] = b; + dead_end[dead_end_top + 2] = c; + dead_end_top += 3; + + // update live triangle counts + live_triangles[a]--; + live_triangles[b]--; + live_triangles[c]--; + + // update cache info + // if vertex is not in cache, put it in cache + if (timestamp - cache_timestamps[a] > cache_size) + cache_timestamps[a] = timestamp++; + + if (timestamp - cache_timestamps[b] > cache_size) + cache_timestamps[b] = timestamp++; + + if (timestamp - cache_timestamps[c] > cache_size) + cache_timestamps[c] = timestamp++; + + // update emitted flags + emitted_flags[triangle] = true; + } + } + + // next candidates are the ones we pushed to dead-end stack just now + const unsigned int* next_candidates_end = &dead_end[0] + dead_end_top; + + // get next vertex + current_vertex = getNextVertexNeighbor(next_candidates_begin, next_candidates_end, &live_triangles[0], &cache_timestamps[0], timestamp, cache_size); + + if (current_vertex == ~0u) + { + current_vertex = getNextVertexDeadEnd(&dead_end[0], dead_end_top, input_cursor, &live_triangles[0], vertex_count); + } + } + + assert(output_triangle == face_count); +} diff --git a/Sources/CMeshOptimizer/vfetchoptimizer.cpp b/Sources/CMeshOptimizer/vfetchoptimizer.cpp new file mode 100644 index 000000000..4847465b6 --- /dev/null +++ b/Sources/CMeshOptimizer/vfetchoptimizer.cpp @@ -0,0 +1,71 @@ +// This file is part of meshoptimizer library; see meshoptimizer.h for version/license details +#include "meshoptimizer.h" + +#include +#include + +size_t meshopt_optimizeVertexFetchRemap(unsigned int* destination, const unsigned int* indices, size_t index_count, size_t vertex_count) +{ + memset(destination, -1, vertex_count * sizeof(unsigned int)); + + unsigned int next_vertex = 0; + + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices[i]; + assert(index < vertex_count); + + if (destination[index] == ~0u) + { + destination[index] = next_vertex++; + } + } + + assert(next_vertex <= vertex_count); + + return next_vertex; +} + +size_t meshopt_optimizeVertexFetch(void* destination, unsigned int* indices, size_t index_count, const void* vertices, size_t vertex_count, size_t vertex_size) +{ + assert(vertex_size > 0 && vertex_size <= 256); + + meshopt_Allocator allocator; + + // support in-place optimization + if (destination == vertices) + { + unsigned char* vertices_copy = allocator.allocate(vertex_count * vertex_size); + memcpy(vertices_copy, vertices, vertex_count * vertex_size); + vertices = vertices_copy; + } + + // build vertex remap table + unsigned int* vertex_remap = allocator.allocate(vertex_count); + memset(vertex_remap, -1, vertex_count * sizeof(unsigned int)); + + unsigned int next_vertex = 0; + + for (size_t i = 0; i < index_count; ++i) + { + unsigned int index = indices[i]; + assert(index < vertex_count); + + unsigned int& remap = vertex_remap[index]; + + if (remap == ~0u) // vertex was not added to destination VB + { + // add vertex + memcpy(static_cast(destination) + next_vertex * vertex_size, static_cast(vertices) + index * vertex_size, vertex_size); + + remap = next_vertex++; + } + + // modify indices in place + indices[i] = remap; + } + + assert(next_vertex <= vertex_count); + + return next_vertex; +} diff --git a/Sources/CShaderTypes/ShaderTypes.h b/Sources/CShaderTypes/ShaderTypes.h index 9ec94b52c..4fb958c9f 100644 --- a/Sources/CShaderTypes/ShaderTypes.h +++ b/Sources/CShaderTypes/ShaderTypes.h @@ -406,6 +406,7 @@ typedef enum{ modelPassMetallicTextureIndex, modelPassNormalTextureIndex, modelPassHeightTextureIndex, + modelPassEmissiveTextureIndex, }ModelPassTextureIndices; typedef enum{ @@ -637,6 +638,12 @@ typedef struct{ // contrast to work with unless that slice is remapped back out first. Identity is (0,1). float heightRemapMin; float heightRemapMax; + // How much of the normal map the surface takes: 1 as authored, 0 none (the surface's + // own normal). Blender's Normal Map "Strength", which a native asset's material carries. + float normalScale; + // 1 when the material has an emissive texture: the light it gives off is then + // `emmissive` times the texture's color, and `emmissive` alone otherwise. + int hasEmissiveTexture; }MaterialParametersUniform; // Runtime-tunable Parallax Occlusion Mapping cost controls (global, not per-material — see @@ -1361,6 +1368,7 @@ typedef enum{ transparencyPassRoughnessTextureIndex, transparencyPassMetallicTextureIndex, transparencyPassNormalTextureIndex, + transparencyPassEmissiveTextureIndex, }TransparencyPassTextureIndices; typedef enum{ @@ -1381,7 +1389,7 @@ typedef enum { } TransparencyPassLightingBufferIndices; typedef enum { - transparencyPassAreaLTCMatTextureIndex = 4, // starts after TransparencyPassTextureIndices + transparencyPassAreaLTCMatTextureIndex = 5, // starts after TransparencyPassTextureIndices transparencyPassAreaLTCMagTextureIndex, // LTC magnitude texture transparencyPassIBLIrradianceTextureIndex, // IBL irradiance map transparencyPassIBLSpecularTextureIndex, // IBL specular map diff --git a/Sources/Demos/DemoUtils/DemoUtils.swift b/Sources/Demos/DemoUtils/DemoUtils.swift index ff343adc0..85880af6b 100644 --- a/Sources/Demos/DemoUtils/DemoUtils.swift +++ b/Sources/Demos/DemoUtils/DemoUtils.swift @@ -1,3 +1,13 @@ +// +// DemoUtils.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + #if os(macOS) import Foundation import simd diff --git a/Sources/Demos/ExporterPipelineDemo/AppDelegate.swift b/Sources/Demos/ExporterPipelineDemo/AppDelegate.swift index 226b0b5c2..267f07fa6 100644 --- a/Sources/Demos/ExporterPipelineDemo/AppDelegate.swift +++ b/Sources/Demos/ExporterPipelineDemo/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // AppDelegate.swift // ExporterPipelineDemo diff --git a/Sources/Demos/ExporterPipelineDemo/GameScene.swift b/Sources/Demos/ExporterPipelineDemo/GameScene.swift index 299fe556a..1f71207d1 100644 --- a/Sources/Demos/ExporterPipelineDemo/GameScene.swift +++ b/Sources/Demos/ExporterPipelineDemo/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameScene.swift // ExporterPipelineDemo diff --git a/Sources/Demos/ExporterPipelineDemo/main.swift b/Sources/Demos/ExporterPipelineDemo/main.swift index e9ef74871..1e4b68f5f 100644 --- a/Sources/Demos/ExporterPipelineDemo/main.swift +++ b/Sources/Demos/ExporterPipelineDemo/main.swift @@ -1,3 +1,13 @@ +// +// main.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // main.swift // ExporterPipelineDemo diff --git a/Sources/Demos/InteractionGameplayDemo/AppDelegate.swift b/Sources/Demos/InteractionGameplayDemo/AppDelegate.swift index 9aada6bf5..71d12813e 100644 --- a/Sources/Demos/InteractionGameplayDemo/AppDelegate.swift +++ b/Sources/Demos/InteractionGameplayDemo/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // AppDelegate.swift // InteractionGameplayDemo diff --git a/Sources/Demos/InteractionGameplayDemo/GameScene.swift b/Sources/Demos/InteractionGameplayDemo/GameScene.swift index 95aabf42f..55586ba9f 100644 --- a/Sources/Demos/InteractionGameplayDemo/GameScene.swift +++ b/Sources/Demos/InteractionGameplayDemo/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameScene.swift // InteractionGameplayDemo diff --git a/Sources/Demos/InteractionGameplayDemo/main.swift b/Sources/Demos/InteractionGameplayDemo/main.swift index 542ce9258..7435196ce 100644 --- a/Sources/Demos/InteractionGameplayDemo/main.swift +++ b/Sources/Demos/InteractionGameplayDemo/main.swift @@ -1,3 +1,13 @@ +// +// main.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // main.swift // InteractionGameplayDemo diff --git a/Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift b/Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift index c55ce9edb..c1412c659 100644 --- a/Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift +++ b/Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // AppDelegate.swift // LargeSceneStreamingDemo diff --git a/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift b/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift index 1fff33ee2..7836198c3 100644 --- a/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift +++ b/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameScene.swift // LargeSceneStreamingDemo diff --git a/Sources/Demos/LargeSceneStreamingDemo/main.swift b/Sources/Demos/LargeSceneStreamingDemo/main.swift index 0be3effa4..82cefd036 100644 --- a/Sources/Demos/LargeSceneStreamingDemo/main.swift +++ b/Sources/Demos/LargeSceneStreamingDemo/main.swift @@ -1,3 +1,13 @@ +// +// main.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // main.swift // LargeSceneStreamingDemo diff --git a/Sources/Demos/LightingDemo/AppDelegate.swift b/Sources/Demos/LightingDemo/AppDelegate.swift index 8fefdea0a..6516f822c 100644 --- a/Sources/Demos/LightingDemo/AppDelegate.swift +++ b/Sources/Demos/LightingDemo/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + #if os(macOS) import AppKit import Observation diff --git a/Sources/Demos/LightingDemo/GameScene.swift b/Sources/Demos/LightingDemo/GameScene.swift index 34b93a15a..72422e4e0 100644 --- a/Sources/Demos/LightingDemo/GameScene.swift +++ b/Sources/Demos/LightingDemo/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + #if os(macOS) import DemoUtils import Foundation diff --git a/Sources/Demos/LightingDemo/main.swift b/Sources/Demos/LightingDemo/main.swift index ba94bcb17..2deff6539 100644 --- a/Sources/Demos/LightingDemo/main.swift +++ b/Sources/Demos/LightingDemo/main.swift @@ -1,3 +1,13 @@ +// +// main.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + #if os(macOS) import AppKit diff --git a/Sources/Demos/RenderingQualityDemo/AppDelegate.swift b/Sources/Demos/RenderingQualityDemo/AppDelegate.swift index 99cbd8222..9457e7309 100644 --- a/Sources/Demos/RenderingQualityDemo/AppDelegate.swift +++ b/Sources/Demos/RenderingQualityDemo/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // AppDelegate.swift // RenderingQualityDemo diff --git a/Sources/Demos/RenderingQualityDemo/GameScene.swift b/Sources/Demos/RenderingQualityDemo/GameScene.swift index cd335d15f..c767262bd 100644 --- a/Sources/Demos/RenderingQualityDemo/GameScene.swift +++ b/Sources/Demos/RenderingQualityDemo/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameScene.swift // RenderingQualityDemo diff --git a/Sources/Demos/RenderingQualityDemo/main.swift b/Sources/Demos/RenderingQualityDemo/main.swift index e3181f43a..270d7f14e 100644 --- a/Sources/Demos/RenderingQualityDemo/main.swift +++ b/Sources/Demos/RenderingQualityDemo/main.swift @@ -1,3 +1,13 @@ +// +// main.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // main.swift // RenderingQualityDemo diff --git a/Sources/Demos/ShowcaseDemo/AppDelegate.swift b/Sources/Demos/ShowcaseDemo/AppDelegate.swift index 353603209..19c5272b5 100644 --- a/Sources/Demos/ShowcaseDemo/AppDelegate.swift +++ b/Sources/Demos/ShowcaseDemo/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // AppDelegate.swift // @@ -11,7 +21,7 @@ @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private enum Constants { - static let appVersion = "0.21.0" + static let appVersion = "0.22.0" static let defaultWindowSize = NSSize(width: 1920, height: 1080) static let minimumWindowSize = NSSize(width: 640, height: 480) } diff --git a/Sources/Demos/ShowcaseDemo/DemoHUD.swift b/Sources/Demos/ShowcaseDemo/DemoHUD.swift index c770f9187..50eee9468 100644 --- a/Sources/Demos/ShowcaseDemo/DemoHUD.swift +++ b/Sources/Demos/ShowcaseDemo/DemoHUD.swift @@ -1,3 +1,13 @@ +// +// DemoHUD.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // DemoHUD.swift // diff --git a/Sources/Demos/ShowcaseDemo/DemoState.swift b/Sources/Demos/ShowcaseDemo/DemoState.swift index 40cc25195..381a04170 100644 --- a/Sources/Demos/ShowcaseDemo/DemoState.swift +++ b/Sources/Demos/ShowcaseDemo/DemoState.swift @@ -1,3 +1,13 @@ +// +// DemoState.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // DemoState.swift // diff --git a/Sources/Demos/ShowcaseDemo/GameScene.swift b/Sources/Demos/ShowcaseDemo/GameScene.swift index 88b4dd2d2..bac82f67c 100644 --- a/Sources/Demos/ShowcaseDemo/GameScene.swift +++ b/Sources/Demos/ShowcaseDemo/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameScene.swift // diff --git a/Sources/Demos/ShowcaseDemo/main.swift b/Sources/Demos/ShowcaseDemo/main.swift index d6bf5f7e2..273124be4 100644 --- a/Sources/Demos/ShowcaseDemo/main.swift +++ b/Sources/Demos/ShowcaseDemo/main.swift @@ -1,3 +1,13 @@ +// +// main.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // main.swift // diff --git a/Sources/Demos/StarterDemo/AppDelegate.swift b/Sources/Demos/StarterDemo/AppDelegate.swift index 2daa6a340..25afafab2 100644 --- a/Sources/Demos/StarterDemo/AppDelegate.swift +++ b/Sources/Demos/StarterDemo/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // AppDelegate.swift // StarterDemo diff --git a/Sources/Demos/StarterDemo/GameScene.swift b/Sources/Demos/StarterDemo/GameScene.swift index 3c56739bc..4bf645149 100644 --- a/Sources/Demos/StarterDemo/GameScene.swift +++ b/Sources/Demos/StarterDemo/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameScene.swift // StarterDemo diff --git a/Sources/Demos/StarterDemo/main.swift b/Sources/Demos/StarterDemo/main.swift index 482d749e5..9e9d89efd 100644 --- a/Sources/Demos/StarterDemo/main.swift +++ b/Sources/Demos/StarterDemo/main.swift @@ -1,3 +1,13 @@ +// +// main.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // main.swift // StarterDemo diff --git a/Sources/Sandbox/AppDelegate.swift b/Sources/Sandbox/AppDelegate.swift index 559a545ab..6f88e3e0e 100644 --- a/Sources/Sandbox/AppDelegate.swift +++ b/Sources/Sandbox/AppDelegate.swift @@ -1,3 +1,13 @@ +// +// AppDelegate.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // AppDelegate.swift // @@ -10,7 +20,7 @@ @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private enum Constants { - static let appVersion = "0.21.0" + static let appVersion = "0.22.0" static let windowSize = NSSize(width: 1600, height: 900) } diff --git a/Sources/Sandbox/GameScene.swift b/Sources/Sandbox/GameScene.swift index 12ddd29ba..6675e91ef 100644 --- a/Sources/Sandbox/GameScene.swift +++ b/Sources/Sandbox/GameScene.swift @@ -1,3 +1,13 @@ +// +// GameScene.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GameScene.swift // diff --git a/Sources/Sandbox/main.swift b/Sources/Sandbox/main.swift index d1fca54f7..4e789720d 100644 --- a/Sources/Sandbox/main.swift +++ b/Sources/Sandbox/main.swift @@ -1,3 +1,13 @@ +// +// main.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // main.swift // diff --git a/Sources/UntoldEngine/Animation/ReachIK.swift b/Sources/UntoldEngine/Animation/ReachIK.swift index 88018fa90..5c2977fcd 100644 --- a/Sources/UntoldEngine/Animation/ReachIK.swift +++ b/Sources/UntoldEngine/Animation/ReachIK.swift @@ -11,13 +11,16 @@ import Foundation import simd -// Reach IK: bends arm chains (shoulder → elbow → hand) toward a world +// Reach IK: bends two-bone chains (shoulder → elbow → hand for an arm, hip +// → knee → ankle for a leg held on a spot) toward a world // target with the two-bone solver foot IK uses. A target within reach is // touched; one beyond it is pointed at, the arm extended to a fraction of // its length along the direction so the elbow never locks. The influence // eases in and out, and the solve blends over whatever the pose (or the // pose layer) put the arms in, so the elbow keeps the posture's bend. Runs -// after the pose layer and before foot IK. See docs/API/UsingPoseLayers.md. +// after the pose layer and before foot IK. Chains share one target (a +// character grabbing at something) or take one each (hands driven by +// tracking). See docs/API/UsingPoseLayers.md. private let ln2: Float = 0.693_147_18 @@ -44,6 +47,33 @@ public struct ReachIKChainDescriptor { } } +/// Where one chain reaches, when the chains do not share a target. +public struct ReachIKChainTarget: Sendable { + public enum Space: Sendable { + /// A world position. + case world + /// A position in the entity's model space. + case model + /// An offset from the chain's own shoulder, along the model axes: + /// the hand keeps its place relative to the body however the body + /// moves this frame. + case shoulder + /// A spot in the entity's model space whose height is ignored: + /// the chain's end goes over it at the height the pose gives it + /// (a foot held where it stands while the pose lifts and lowers + /// it). + case modelGround + } + + public var position: simd_float3 + public var space: Space + + public init(position: simd_float3, space: Space = .world) { + self.position = position + self.space = space + } +} + /// Per-entity reach IK state. struct ReachIKState { var descriptors: [ReachIKChainDescriptor] = [] @@ -57,6 +87,9 @@ struct ReachIKState { /// instead of one. Nil until the first target. var smoothedTarget: simd_float3? var targetHalflife: Float = 0.08 + /// The same easing for the chains' own targets: theirs to set, so a + /// tracked hand followed exactly does not make the shared target jump. + var chainTargetHalflife: Float = 0.08 var weight: Float = 0 var targetWeight: Float = 0 var halflife: Float = 0.25 @@ -65,6 +98,12 @@ struct ReachIKState { /// Per-chain multipliers on the influence, index-aligned with the /// chains (empty: 1 for all) — one hand lunging while the other holds. var chainWeights: [Float] = [] + /// Per-chain targets, index-aligned with the chains; a chain without + /// one reaches for `targetWorld`. + var chainTargets: [ReachIKChainTarget?] = [] + /// The chain targets as the solve uses them, eased like + /// `smoothedTarget`, each in its target's own space. + var smoothedChainTargets: [ReachIKChainTarget?] = [] var jointPositions: [simd_float3] = [] var jointRotations: [simd_quatf] = [] @@ -73,6 +112,30 @@ struct ReachIKState { resolvedChains = nil } + var hasChainTargets: Bool { + chainTargets.contains { $0 != nil } + } + + /// The chain's own target eased toward its newest value; a target that + /// is new, or changed space, is taken as it is. + mutating func easedChainTarget(_ index: Int, ease: Float) -> ReachIKChainTarget? { + guard index < chainTargets.count, let target = chainTargets[index] else { + if index < smoothedChainTargets.count { + smoothedChainTargets[index] = nil + } + return nil + } + if smoothedChainTargets.count <= index { + smoothedChainTargets += [ReachIKChainTarget?](repeating: nil, count: index + 1 - smoothedChainTargets.count) + } + var eased = target + if let previous = smoothedChainTargets[index], previous.space == target.space { + eased.position = previous.position + (target.position - previous.position) * ease + } + smoothedChainTargets[index] = eased + return eased + } + mutating func refreshForwardKinematics(pose: PoseBuffer, parentIndices: [Int?]) { computeForwardKinematics( pose: pose, parentIndices: parentIndices, @@ -112,19 +175,25 @@ func applyReachIK( animationComponent.reachIK.weight = animationComponent.reachIK.targetWeight } let weight = animationComponent.reachIK.weight - guard weight > 1e-4, let rawTarget = animationComponent.reachIK.targetWorld else { + let rawTarget = animationComponent.reachIK.targetWorld + guard weight > 1e-4, rawTarget != nil || animationComponent.reachIK.hasChainTargets else { if animationComponent.reachIK.targetWeight <= 0 { animationComponent.reachIK.targetWorld = nil animationComponent.reachIK.smoothedTarget = nil + animationComponent.reachIK.chainTargets.removeAll() + animationComponent.reachIK.smoothedChainTargets.removeAll() } return } - let targetWorld: simd_float3 - if let previous = animationComponent.reachIK.smoothedTarget { - let ease = 1 - exp(-ln2 * deltaTime / max(animationComponent.reachIK.targetHalflife, 1e-4)) - targetWorld = previous + (rawTarget - previous) * ease - } else { - targetWorld = rawTarget + let ease = 1 - exp(-ln2 * deltaTime / max(animationComponent.reachIK.targetHalflife, 1e-4)) + let chainEase = 1 - exp(-ln2 * deltaTime / max(animationComponent.reachIK.chainTargetHalflife, 1e-4)) + var targetWorld: simd_float3? + if let rawTarget { + if let previous = animationComponent.reachIK.smoothedTarget { + targetWorld = previous + (rawTarget - previous) * ease + } else { + targetWorld = rawTarget + } } animationComponent.reachIK.smoothedTarget = targetWorld @@ -138,12 +207,19 @@ func applyReachIK( let rotations = animationComponent.reachIK.jointRotations let worldMatrix = scene.get(component: WorldTransformComponent.self, for: entityId)?.space ?? .identity - let targetModel4 = worldMatrix.inverse * simd_float4(targetWorld, 1) - let targetModel = simd_float3(targetModel4.x, targetModel4.y, targetModel4.z) + let worldToModel = worldMatrix.inverse + func modelPosition(_ world: simd_float3) -> simd_float3 { + let p = worldToModel * simd_float4(world, 1) + return simd_float3(p.x, p.y, p.z) + } + let targetModel = targetWorld.map(modelPosition) let reach = animationComponent.reachIK.reach let chainWeights = animationComponent.reachIK.chainWeights for (index, chain) in chains.enumerated() { + // Eased every frame, also while the chain's weight is zero, so + // the target is current when the weight returns. + let chainTarget = animationComponent.reachIK.easedChainTarget(index, ease: chainEase) let chainWeight = weight * (index < chainWeights.count ? min(max(chainWeights[index], 0), 1) : 1) guard chainWeight > 1e-4 else { continue } guard chain.shoulder < pose.jointCount, chain.elbow < pose.jointCount, chain.hand < pose.jointCount else { @@ -157,7 +233,19 @@ func applyReachIK( // Beyond reach the arm points at the target, a little short of // straight. - var goal = targetModel + var goal: simd_float3 + if let chainTarget { + switch chainTarget.space { + case .world: goal = modelPosition(chainTarget.position) + case .model: goal = chainTarget.position + case .shoulder: goal = shoulder + chainTarget.position + case .modelGround: goal = simd_float3(chainTarget.position.x, hand.y, chainTarget.position.z) + } + } else if let targetModel { + goal = targetModel + } else { + continue + } let toTarget = goal - shoulder let distance = simd_length(toTarget) guard distance > 1e-4 else { continue } diff --git a/Sources/UntoldEngine/AssetFormat/UntoldFormat.swift b/Sources/UntoldEngine/AssetFormat/UntoldFormat.swift index ee599638a..f6c710afd 100644 --- a/Sources/UntoldEngine/AssetFormat/UntoldFormat.swift +++ b/Sources/UntoldEngine/AssetFormat/UntoldFormat.swift @@ -88,6 +88,14 @@ public enum UntoldFileType: UInt32, Sendable { case animation = 5 } +/// Bits of `UntoldFileHeaderV1.flags`. +public enum UntoldFileFlags { + /// The file is a level of a model's automatic LOD chain, written by + /// `UntoldMeshLODCooker` (module UntoldEngineMeshCook). A cook replaces the files + /// that carry it and leaves any other file alone, whatever its name. + public static let generatedLODLevel: UInt32 = 1 << 0 +} + public struct UntoldChunkType: RawRepresentable, Hashable, Sendable, Equatable { public let rawValue: UInt32 diff --git a/Sources/UntoldEngine/ECS/ComponentPool.swift b/Sources/UntoldEngine/ECS/ComponentPool.swift index 4e479751c..70303f54d 100644 --- a/Sources/UntoldEngine/ECS/ComponentPool.swift +++ b/Sources/UntoldEngine/ECS/ComponentPool.swift @@ -95,6 +95,60 @@ public protocol Component { init() // Requires a default initializer } +/// How a component gets into its slot and out of it. +/// +/// A slot is read without the scene's lock: the scene is handed out by value under the +/// lock and the copy is read after it, and a render pass may keep a copy for as long as +/// it runs. While a copy says a slot holds a component, another thread may put a new +/// one there: the entity is given the component again, or it lost the component and +/// gets it back, or it was destroyed and a new entity took its index. +/// +/// So a component that is an object, as the engine's are, goes into its slot with an +/// atomic store that comes after everything its initializer wrote, and comes out with +/// an atomic load that takes that order. A reader gets the component the slot had or +/// the new one, and either one whole. +/// +/// A component that is a value is not one reference and cannot be swapped in one step. +/// It is written and read in place: do not give one again to an entity that another +/// thread may be reading. +enum ComponentSlot { + /// Whether a slot of `T` holds one reference to an object. + static func holdsReference(_: T.Type) -> Bool { + T.self is AnyObject.Type + } + + /// Puts `component` in `slot`, which owns it from here on. What the slot held is + /// written over, not released. + @inline(__always) + static func store(_ component: T, in slot: UnsafeMutableRawPointer, asReference: Bool) { + guard asReference else { + slot.bindMemory(to: T.self, capacity: 1).initialize(to: component) + return + } + let reference = unsafeBitCast(component, to: UnsafeRawPointer.self) + // The slot's own reference to the object. + _ = Unmanaged.fromOpaque(reference).retain() + // Before macOS 15 and iOS 18 (Synchronization) the standard library has an atomic + // compare and exchange of a pointer and no plain atomic store. A scene is + // written under its lock, so the exchange has nobody to lose to. + var held = slot.load(as: UnsafeRawPointer?.self) + let word = slot.assumingMemoryBound(to: UnsafeRawPointer?.self) + while !_stdlib_atomicCompareExchangeStrongPtr(object: word, expected: &held, desired: reference) {} + } + + /// The component in `slot`, read as a `T`. Only for a slot that holds one. + @inline(__always) + static func load(from slot: UnsafeMutableRawPointer, as _: T.Type, asReference: Bool) -> T? { + guard asReference else { + return slot.assumingMemoryBound(to: T.self).pointee + } + guard let reference = _stdlib_atomicAcquiringLoadARCRef(object: slot.assumingMemoryBound(to: AnyObject?.self)) else { + return nil + } + return unsafeBitCast(reference.toOpaque(), to: T.self) + } +} + /// The components that left a pool and are not released yet. /// /// A component leaves its slot when it is removed from its entity, when its entity is @@ -147,6 +201,8 @@ public struct ComponentPool { private struct Header { let chunkCount: Int let elementSize: Int + /// Whether a slot holds one reference to an object (see ComponentSlot). + let holdsReferences: Bool var quarantine: ComponentQuarantine } @@ -160,19 +216,26 @@ public struct ComponentPool { private var departures: UInt64 = 0 init(for type: T.Type) { - storage = Self.makeStorage(chunks: [], elementSize: MemoryLayout.stride, quarantine: ComponentQuarantine(of: type)) + storage = Self.makeStorage( + chunks: [], elementSize: MemoryLayout.stride, holdsReferences: ComponentSlot.holdsReference(T.self), + quarantine: ComponentQuarantine(of: type) + ) } private static func makeStorage( - chunks: [UnsafeMutableRawPointer], elementSize: Int, quarantine: ComponentQuarantine + chunks: [UnsafeMutableRawPointer], elementSize: Int, holdsReferences: Bool, quarantine: ComponentQuarantine ) -> Storage { let storage = Storage.create(minimumCapacity: chunks.count) { _ in - Header(chunkCount: chunks.count, elementSize: elementSize, quarantine: quarantine) + Header(chunkCount: chunks.count, elementSize: elementSize, holdsReferences: holdsReferences, quarantine: quarantine) } storage.withUnsafeMutablePointerToElements { $0.initialize(from: chunks, count: chunks.count) } return storage } + private var holdsReferences: Bool { + storage.withUnsafeMutablePointerToHeader { $0.pointee.holdsReferences } + } + private var chunks: [UnsafeMutableRawPointer] { storage.withUnsafeMutablePointers { header, chunks in Array(UnsafeBufferPointer(start: chunks, count: header.pointee.chunkCount)) @@ -196,7 +259,7 @@ public struct ComponentPool { for chunk in chunks { chunk.deallocate() } - storage = Self.makeStorage(chunks: [], elementSize: elementSize, quarantine: quarantine) + storage = Self.makeStorage(chunks: [], elementSize: elementSize, holdsReferences: holdsReferences, quarantine: quarantine) } /// Adds chunks until `index` has storage. @@ -211,7 +274,7 @@ public struct ComponentPool { ) ) } - storage = Self.makeStorage(chunks: chunks, elementSize: elementSize, quarantine: quarantine) + storage = Self.makeStorage(chunks: chunks, elementSize: elementSize, holdsReferences: holdsReferences, quarantine: quarantine) } public func get(_ index: Int) -> UnsafeMutableRawPointer? { @@ -221,16 +284,30 @@ public struct ComponentPool { } } + /// Where the slot at `index` is, and whether it holds one reference to an object: + /// what ComponentSlot needs to read it or write it. Read and write a component + /// through ComponentSlot, not through the pointer `get` returns. + @inline(__always) + func slot(at index: Int) -> (address: UnsafeMutableRawPointer, holdsReference: Bool)? { + storage.withUnsafeMutablePointers { header, chunks in + guard index >= 0, index < header.pointee.chunkCount * Self.chunkCapacity else { return nil } + let address = chunks[index / Self.chunkCapacity].advanced(by: (index % Self.chunkCapacity) * header.pointee.elementSize) + return (address, header.pointee.holdsReferences) + } + } + /// The component in the slot at `index`, read as a `T`. Only for a slot the entity's /// mask says holds one. /// - /// Read a slot through this: the pool is kept until the component is in the caller's - /// hands, and a pool that is kept holds back the release of what left it. + /// For a reader that holds a pool on its own: the pool is kept until the component is + /// in the caller's hands, and a pool that is kept holds back the release of what left + /// it. A reader that holds the scene (Scene.get) asks for the slot and reads it: the + /// scene it holds keeps the pool. @inline(__always) func component(at index: Int, as _: T.Type) -> T? { - guard let slot = get(index) else { return nil } + guard let slot = slot(at: index) else { return nil } return withExtendedLifetime(storage) { - slot.bindMemory(to: T.self, capacity: 1).pointee + ComponentSlot.load(from: slot.address, as: T.self, asReference: slot.holdsReference) } } @@ -274,10 +351,10 @@ public struct ComponentPool { } /// Add a new component to the pool at a specified index - public mutating func add(component: T, at index: Int) { + public mutating func add(component: some Component, at index: Int) { reserve(upTo: index) - guard let pointer = get(index) else { fatalError("No storage for entity index \(index) in ComponentPool.") } - pointer.assumingMemoryBound(to: T.self).initialize(to: component) + guard let slot = slot(at: index) else { fatalError("No storage for entity index \(index) in ComponentPool.") } + ComponentSlot.store(component, in: slot.address, asReference: slot.holdsReference) } } diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index d09ac09e9..f6f124d42 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -912,6 +912,12 @@ public class LODComponent: Component { /// Mesh asset identifier for batching (material hash + LOD) public var activeMeshAssetID: String = "" + /// True when every level is the same surface at a different detail, mesh for mesh + /// (an automatic LOD chain): the materials then belong to the entity, not to a + /// level, and a switch carries them over, so a material edited or streamed while + /// one level is drawn stays when another takes its place. + public var levelsShareMaterials: Bool = false + public required init() {} /// Check if the desired LOD level has a resident mesh @@ -1150,7 +1156,8 @@ public enum TileVisualState { /// entire tiled scene as a single logical object. /// /// - Note: Root-entity transforms are not propagated to streaming/culling -/// bounds in this release. Keep the root at identity transform. +/// bounds in this release. `setEntityStreamScene` resets the root's own +/// transform to identity; keep it, and the root's ancestors, there. public class TiledSceneComponent: Component { /// Human-readable label derived from the manifest filename. public var manifestLabel: String = "" diff --git a/Sources/UntoldEngine/ECS/Scenes.swift b/Sources/UntoldEngine/ECS/Scenes.swift index 92077bf63..f4a2c8f9c 100644 --- a/Sources/UntoldEngine/ECS/Scenes.swift +++ b/Sources/UntoldEngine/ECS/Scenes.swift @@ -180,19 +180,20 @@ public struct Scene { } // Allocate and initialize a new component in the pool - guard let componentPointer = pool.get(Int(entityIndex)) else { + guard let slot = pool.slot(at: Int(entityIndex)) else { handleError(.failedToGetComponentPointer) return nil } - let typedPointer = componentPointer.bindMemory(to: T.self, capacity: 1) - typedPointer.initialize(to: T()) + // A copy of the scene taken before this may be reading the slot (see ComponentSlot). + let component = T() + ComponentSlot.store(component, in: slot.address, asReference: slot.holdsReference) // Set the bit for this component to true entities[Int(entityIndex)].mask.set(componentId) componentIndex[componentId, default: []].insert(entityId) - return typedPointer.pointee + return component } public func get(component _: T.Type, for entityId: EntityID) -> T? { @@ -219,13 +220,15 @@ public struct Scene { return nil } - // Retrieve the specific component pool - guard let pool = componentPool[componentId] else { + // Ask the specific component pool where the component is. The pool is let go + // before the slot is read: kept across the read, it is retained and released + // on every call. This scene, which the caller holds, keeps the pool's storage. + guard let slot = componentPool[componentId]?.slot(at: Int(entityIndex)) else { return nil } - // Get the component from the pool - return pool.component(at: Int(entityIndex), as: T.self) + // Get the component from its slot + return ComponentSlot.load(from: slot.address, as: T.self, asReference: slot.holdsReference) } /// Moves the component in the slot of the entity at `entityIndex` to its pool's diff --git a/Sources/UntoldEngine/Mesh/Mesh+GPUMemory.swift b/Sources/UntoldEngine/Mesh/Mesh+GPUMemory.swift index 23d46b2fd..e3c08ab83 100644 --- a/Sources/UntoldEngine/Mesh/Mesh+GPUMemory.swift +++ b/Sources/UntoldEngine/Mesh/Mesh+GPUMemory.swift @@ -114,6 +114,44 @@ public func calculateMeshArrayTotalMemory(_ meshes: [Mesh]) -> Int { meshes.reduce(0) { $0 + $1.totalGPUMemorySize } } +// MARK: - GPU Allocations + +/// The vertex, index and skinning buffers `meshes` hold. One that several meshes or +/// submeshes use is listed for each; `MemoryBudgetManager` counts it once. +func meshBufferAllocations(of meshes: [Mesh]) -> [GPUAllocation] { + var allocations: [GPUAllocation] = [] + for mesh in meshes { + for vertexBuffer in mesh.metalKitMesh.vertexBuffers { + allocations.append(GPUAllocation(object: vertexBuffer.buffer, bytes: vertexBuffer.buffer.length)) + } + for submesh in mesh.metalKitMesh.submeshes { + allocations.append(GPUAllocation(object: submesh.indexBuffer.buffer, bytes: submesh.indexBuffer.buffer.length)) + } + if let ring = mesh.skin?.jointTransformsRing { + for buffer in ring.buffers { + allocations.append(GPUAllocation(object: buffer, bytes: buffer.length)) + } + } + } + return allocations +} + +/// The material textures `meshes` hold: those `SubMesh.textureMemorySize` measures. +func textureAllocations(of meshes: [Mesh]) -> [GPUAllocation] { + var allocations: [GPUAllocation] = [] + for mesh in meshes { + for submesh in mesh.submeshes { + guard let material = submesh.material else { continue } + for texture in [material.baseColor.texture, material.normal.texture, material.metallic.texture, material.roughness.texture] { + if let texture { + allocations.append(GPUAllocation(object: texture, bytes: texture.allocatedSize)) + } + } + } + } + return allocations +} + // MARK: - Memory Formatting Helpers public extension Int { diff --git a/Sources/UntoldEngine/Mesh/Mesh.swift b/Sources/UntoldEngine/Mesh/Mesh.swift index 6132e9e82..9c98d6d5d 100644 --- a/Sources/UntoldEngine/Mesh/Mesh.swift +++ b/Sources/UntoldEngine/Mesh/Mesh.swift @@ -40,6 +40,10 @@ public enum CoordinateSystemConversion: Sendable { public struct Mesh { public let metalKitMesh: MTKMesh + /// The Metal buffers of `metalKitMesh.vertexBuffers`, in the same order, read once + /// when the mesh is made. The passes bind them for every draw, and MetalKit builds a + /// new array, whose elements answer through Objective-C, each time it is asked. + let vertexBuffers: [MTLBuffer] public var submeshes: [SubMesh] = [] public var localSpace: simd_float4x4 = .identity public var worldSpace: simd_float4x4 = .identity @@ -91,6 +95,7 @@ public struct Mesh { return nil } metalKitMesh = localMetalKitMesh + vertexBuffers = localMetalKitMesh.vertexBuffers.map(\.buffer) submeshes = modelIOMesh.submeshes?.enumerated().compactMap { index, element in guard let mdlSubmesh = element as? MDLSubmesh else { return nil } @@ -484,12 +489,26 @@ public struct SubMesh { public let metalKitSubmesh: MTKSubmesh public var material: Material? + // What a draw of the submesh hands to Metal, read from `metalKitSubmesh` once when + // the submesh is made: asking MetalKit for them costs half a dozen Objective-C calls + // per draw. + let primitiveType: MTLPrimitiveType + let indexCount: Int + let indexType: MTLIndexType + let indexBuffer: MTLBuffer + let indexBufferOffset: Int + init(metalKitSubmesh: MTKSubmesh) { self.metalKitSubmesh = metalKitSubmesh + primitiveType = metalKitSubmesh.primitiveType + indexCount = metalKitSubmesh.indexCount + indexType = metalKitSubmesh.indexType + indexBuffer = metalKitSubmesh.indexBuffer.buffer + indexBufferOffset = metalKitSubmesh.indexBuffer.offset } init(modelIOSubmesh: MDLSubmesh, metalKitSubmesh: MTKSubmesh, textureLoader: TextureLoader) { - self.metalKitSubmesh = metalKitSubmesh + self.init(metalKitSubmesh: metalKitSubmesh) // Fallback to an empty material if none is provided if let mdlMaterial = modelIOSubmesh.material { @@ -784,6 +803,9 @@ public struct Material { public var metallicValue: Float = 0.0 public var roughnessChannel: UntoldTextureChannel = .r public var metallicChannel: UntoldTextureChannel = .r + /// How much of the normal map the surface takes: 1 as authored, 0 none. A native + /// asset's material carries its normal strength here (Blender's Normal Map "Strength"). + public var normalScale: Float = 1.0 // Disney material properties public var specular: Float = 0.0 @@ -877,6 +899,16 @@ public struct Material { ) } + // Many models share an image (a pack's shared Textures/ folder): it is decoded + // once while any material still holds it, also when several of those models + // load at the same time. + let cacheKey = LoadedTextureCache.key(url: url, isSRGB: isSRGB) + return LoadedTextureCache.shared.texture(for: cacheKey) { + decodeRuntimeTexture(label, url: url, isSRGB: isSRGB) + } + } + + func decodeRuntimeTexture(_ label: String, url: URL, isSRGB: Bool) -> MTLTexture? { let options: [MTKTextureLoader.Option: Any] = [ .textureUsage: NSNumber(value: MTLTextureUsage([.shaderRead, .pixelFormatView]).rawValue), .textureStorageMode: NSNumber(value: MTLStorageMode.private.rawValue), @@ -884,34 +916,14 @@ public struct Material { .generateMipmaps: NSNumber(value: true), ] - // Grayscale PNGs produce an r8Unorm Metal texture. The shader samples it as - // RGBA where G=B=0, making the mesh appear solid red. Detect and expand to - // RGBA via Core Graphics before handing off to MTKTextureLoader. - if let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil), - let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil), - cgImage.colorSpace?.model == .monochrome - { - let w = cgImage.width, h = cgImage.height - let colorSpace = isSRGB - ? (CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()) - : CGColorSpaceCreateDeviceRGB() - if let ctx = CGContext( - data: nil, width: w, height: h, - bitsPerComponent: 8, bytesPerRow: w * 4, - space: colorSpace, - bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue).rawValue - ) { - ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: w, height: h)) - if let rgbaImage = ctx.makeImage(), - let texture = try? textureLoader.newTexture(cgImage: rgbaImage, options: options) - { - Logger.log( - message: "[UntoldTexture] Expanded grayscale \(label.lowercased()) to RGBA '\(runtimeMaterial.name ?? "")' \(texture.width)x\(texture.height)", - category: LogCategory.textureLoading.rawValue - ) - return texture - } - } + // A grayscale image loads as a texture of one channel, which a shader reading a + // color from it shows as solid red: load it with that channel in red, green and blue. + if let texture = loadGrayscaleTextureAsRGBA(url: url, isSRGB: isSRGB, loader: textureLoader, options: options) { + Logger.log( + message: "[UntoldTexture] Expanded grayscale \(label.lowercased()) to RGBA '\(runtimeMaterial.name ?? "")' \(texture.width)x\(texture.height)", + category: LogCategory.textureLoading.rawValue + ) + return texture } do { @@ -995,6 +1007,7 @@ public struct Material { metallicValue = runtimeMaterial.metallicFactor roughnessChannel = runtimeMaterial.roughnessTextureChannel metallicChannel = runtimeMaterial.metallicTextureChannel + normalScale = runtimeMaterial.normalScale alphaCutoff = runtimeMaterial.alphaCutoff heightScale = runtimeMaterial.heightScale heightMidlevel = runtimeMaterial.heightMidlevel @@ -1064,6 +1077,11 @@ public struct Material { mapType: "Emissive map" ) emissive = createTextureDescriptor(device: renderInfo.device, texture: emissiveTex, wrapMode: .repeat) + // The surface gives off its emissive color times its emissive texture. A source + // material with the texture alone means the texture as painted, so its color is white. + if emissiveTex != nil { + emissiveValue = simd_float3(repeating: 1.0) + } let heightTex = textureLoader.loadTexture( from: mdlMaterial.property(with: .displacement), @@ -1720,6 +1738,74 @@ final class TextureLoader { } } +/// Loads a grayscale image as a texture that carries its one channel in red, green and +/// blue, so that a shader reading a color from it sees gray and not red. Returns nil when +/// the image is not grayscale: the caller then loads it as it is. +/// +/// MTKTextureLoader takes the `.SRGB` option from a file and ignores it for a CGImage: the +/// texture comes back as plain rgba8Unorm, and a color texture would be read as linear +/// values, more than twice as bright in the middle tones. A color image is therefore +/// handed back as an sRGB view of that texture. +func loadGrayscaleTextureAsRGBA( + url: URL, + isSRGB: Bool, + loader: MTKTextureLoader, + options: [MTKTextureLoader.Option: Any] +) -> MTLTexture? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil), + cgImage.colorSpace?.model == .monochrome + else { return nil } + + let width = cgImage.width + let height = cgImage.height + let colorSpace = isSRGB + ? (CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()) + : CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue).rawValue + ) else { return nil } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + guard let rgbaImage = context.makeImage(), + let texture = try? loader.newTexture(cgImage: rgbaImage, options: options) + else { return nil } + + return isSRGB ? sRGBView(of: texture) : texture +} + +/// The texture read as sRGB values: a view of it in the sRGB twin of its format, or the +/// texture itself when it has none or is one already. +private func sRGBView(of texture: MTLTexture) -> MTLTexture { + let sRGBFormat: MTLPixelFormat + switch texture.pixelFormat { + case .rgba8Unorm: sRGBFormat = .rgba8Unorm_srgb + case .bgra8Unorm: sRGBFormat = .bgra8Unorm_srgb + default: return texture + } + guard let view = texture.makeTextureView(pixelFormat: sRGBFormat) else { return texture } + view.label = texture.label + + // The smaller levels were averaged from the stored values. Build them again through + // the view, which averages the light those values stand for, as for any color texture. + if view.mipmapLevelCount > 1, + let commandBuffer = renderInfo.commandQueue?.makeCommandBuffer(), + let blitEncoder = commandBuffer.makeBlitCommandEncoder() + { + blitEncoder.generateMipmaps(for: view) + blitEncoder.endEncoding() + commandBuffer.commit() + } + return view +} + func createTextureDescriptor(device: MTLDevice, texture: MTLTexture?, wrapMode: WrapMode) -> TextureDescriptor @@ -1766,3 +1852,58 @@ private func cachedSamplerState(device: MTLDevice, wrapMode: WrapMode) -> MTLSam return sampler } + +/// Image textures (PNG, JPEG, ...) loaded for runtime materials, by file and colour +/// space, held only while some material still uses them (weak values). Models that +/// share an image, such as the models of a `.untoldpack` with its shared Textures/ +/// folder, decode and upload it once instead of once per model. `.utex` textures +/// have their own cache (NativeTextureLoader.sharedCache). +/// +/// Sharing is safe because loaded textures are never written after loading: material +/// edits and texture streaming replace a material's texture rather than change it. +/// +/// Single-flight, as `NativeTextureLoader` is: materials are built on several threads +/// at once (a pack loads eight models at a time), and a thread that asks for an image +/// another one is decoding waits for it and takes its texture. The callers build their +/// materials synchronously, so they wait on a condition, and the lock is never held +/// while an image is decoded. +final class LoadedTextureCache: @unchecked Sendable { + static let shared = LoadedTextureCache() + + /// Guards `textures` and `loading`, and wakes the threads waiting for a load. + private let condition = NSCondition() + private let textures = NSMapTable(keyOptions: .strongMemory, valueOptions: .weakMemory) + /// The keys one thread is loading right now. + private var loading: Set = [] + + static func key(url: URL, isSRGB: Bool) -> String { + "\(url.standardizedFileURL.path)|srgb=\(isSRGB)" + } + + /// The texture cached for `key`, or the one `load` returns, which is then cached. + /// `load` runs on the calling thread, and for one caller at a time per key. A load + /// that fails is not remembered: the next caller tries again. + func texture(for key: String, load: () -> MTLTexture?) -> MTLTexture? { + condition.lock() + while loading.contains(key) { + condition.wait() + } + if let cached = textures.object(forKey: key as NSString) as? MTLTexture { + condition.unlock() + return cached + } + loading.insert(key) + condition.unlock() + + let texture = load() + + condition.lock() + if let texture { + textures.setObject(texture as AnyObject, forKey: key as NSString) + } + loading.remove(key) + condition.broadcast() + condition.unlock() + return texture + } +} diff --git a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift index 97ab709be..119474c6c 100644 --- a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift +++ b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift @@ -464,7 +464,7 @@ private func drawModelSurfaceMeshes( renderEncoder: MTLRenderCommandEncoder ) { for mesh in meshes { - guard mesh.metalKitMesh.vertexBuffers.count > Int(modelPassJointWeightsIndex.rawValue) else { + guard mesh.vertexBuffers.count > Int(modelPassJointWeightsIndex.rawValue) else { continue } @@ -498,11 +498,11 @@ private func drawModelSurfaceMeshes( for submesh in mesh.submeshes { renderEncoder.drawIndexedPrimitivesTracked( - type: submesh.metalKitSubmesh.primitiveType, - indexCount: submesh.metalKitSubmesh.indexCount, - indexType: submesh.metalKitSubmesh.indexType, - indexBuffer: submesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: submesh.metalKitSubmesh.indexBuffer.offset, + type: submesh.primitiveType, + indexCount: submesh.indexCount, + indexType: submesh.indexType, + indexBuffer: submesh.indexBuffer, + indexBufferOffset: submesh.indexBufferOffset, category: .other ) } diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 3de8e29be..f8de7afea 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -641,6 +641,9 @@ public enum RenderPasses { var result: [EntityID] = [] result.reserveCapacity(candidates.count / 4) + // An object too small to be drawn casts a shadow about as small. + let smallObjectCulling = SmallObjectCulling.forCurrentFrame() + for entityId in candidates { guard scene.mask(for: entityId) != nil else { continue } if shouldHideSceneEntity(entityId: entityId) { continue } @@ -671,6 +674,7 @@ public enum RenderPasses { localMax: localTransformComponent.boundingBox.max, worldMatrix: worldTransformComponent.space ) + if let smallObjectCulling, smallObjectCulling.culls(worldMin: worldMin, worldMax: worldMax) { continue } // Directional-light caster relevance cannot be determined from camera // distance or the cascade receiver split — a caster outside a cascade's // camera-depth interval can still project a shadow into that interval. @@ -731,6 +735,7 @@ public enum RenderPasses { let lightPosition = shadowLight.light.position let maxDistance = max(shadowLight.light.attenuation.w, minimumSpotShadowDistance) + let smallObjectCulling = SmallObjectCulling.forCurrentFrame() var result: [EntityID] = [] result.reserveCapacity(candidates.count / 4) @@ -754,6 +759,7 @@ public enum RenderPasses { localMax: localTransformComponent.boundingBox.max, worldMatrix: worldTransformComponent.space ) + if let smallObjectCulling, smallObjectCulling.culls(worldMin: worldMin, worldMax: worldMax) { continue } if shadowEntityBeyondMaxDistance( worldMin: worldMin, worldMax: worldMax, @@ -787,6 +793,7 @@ public enum RenderPasses { let lightPosition = shadowLight.light.position let maxDistance = max(shadowLight.light.radius, minimumPointShadowDistance) + let smallObjectCulling = SmallObjectCulling.forCurrentFrame() var result: [EntityID] = [] result.reserveCapacity(candidates.count / 4) @@ -811,6 +818,7 @@ public enum RenderPasses { localMax: localTransformComponent.boundingBox.max, worldMatrix: worldTransformComponent.space ) + if let smallObjectCulling, smallObjectCulling.culls(worldMin: worldMin, worldMax: worldMax) { continue } if shadowEntityBeyondMaxDistance( worldMin: worldMin, worldMax: worldMax, @@ -897,6 +905,8 @@ public enum RenderPasses { materialParameters.heightMidlevel = material.heightMidlevel materialParameters.heightRemapMin = material.heightRemapMin materialParameters.heightRemapMax = material.heightRemapMax + materialParameters.normalScale = material.normalScale + materialParameters.hasEmissiveTexture = material.hasEmissiveMap ? 1 : 0 } /// Builds the GPU-side POM quality uniform from the current global `POMQualitySettings` @@ -1361,11 +1371,11 @@ public enum RenderPasses { for subMesh in mesh.submeshes { renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset, category: .shadow ) } @@ -1554,11 +1564,11 @@ public enum RenderPasses { for subMesh in mesh.submeshes { renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset, category: .shadow ) } @@ -1684,11 +1694,11 @@ public enum RenderPasses { for subMesh in mesh.submeshes { renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset, category: .shadow ) } @@ -1978,13 +1988,14 @@ public enum RenderPasses { ) renderEncoder.setFragmentSamplerState(material.height.sampler, index: Int(modelPassHeightSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(material.emissive.texture, index: Int(modelPassEmissiveTextureIndex.rawValue)) renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset, category: .opaque ) } @@ -2204,6 +2215,7 @@ public enum RenderPasses { renderEncoder.setFragmentSamplerState(material.normal.sampler, index: Int(modelPassNormalSamplerIndex.rawValue)) renderEncoder.setFragmentTexture(material.height.texture, index: Int(modelPassHeightTextureIndex.rawValue)) renderEncoder.setFragmentSamplerState(material.height.sampler, index: Int(modelPassHeightSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(material.emissive.texture, index: Int(modelPassEmissiveTextureIndex.rawValue)) // SINGLE DRAW CALL FOR ENTIRE BATCH // Logger.log(message: "āœ… Drawing batch \(batchGroup.id): \(batchGroup.indexCount) indices, \(batchGroup.vertexCount) vertices") @@ -2387,13 +2399,14 @@ public enum RenderPasses { renderEncoder.setFragmentSamplerState(material.normal.sampler, index: Int(modelPassNormalSamplerIndex.rawValue)) renderEncoder.setFragmentTexture(material.height.texture, index: Int(modelPassHeightTextureIndex.rawValue)) renderEncoder.setFragmentSamplerState(material.height.sampler, index: Int(modelPassHeightSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(material.emissive.texture, index: Int(modelPassEmissiveTextureIndex.rawValue)) renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset, category: .opaque ) } @@ -2484,6 +2497,7 @@ public enum RenderPasses { renderEncoder.setFragmentSamplerState(material.normal.sampler, index: Int(modelPassNormalSamplerIndex.rawValue)) renderEncoder.setFragmentTexture(material.height.texture, index: Int(modelPassHeightTextureIndex.rawValue)) renderEncoder.setFragmentSamplerState(material.height.sampler, index: Int(modelPassHeightSamplerIndex.rawValue)) + renderEncoder.setFragmentTexture(material.emissive.texture, index: Int(modelPassEmissiveTextureIndex.rawValue)) renderEncoder.drawIndexedPrimitivesTracked( type: .triangle, @@ -3836,13 +3850,17 @@ public enum RenderPasses { material.normal.sampler, index: Int(transparencyPassNormalSamplerIndex.rawValue) ) + renderEncoder.setFragmentTexture( + material.emissive.texture, + index: Int(transparencyPassEmissiveTextureIndex.rawValue) + ) renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset, category: .transparent ) } @@ -3958,11 +3976,11 @@ public enum RenderPasses { for subMesh in mesh.submeshes where subMesh.material?.alphaMode != .blend { renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset, category: .opaque ) } @@ -4092,11 +4110,11 @@ public enum RenderPasses { for subMesh in mesh.submeshes { renderEncoder.drawIndexedPrimitives( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset ) } } @@ -4320,11 +4338,11 @@ public enum RenderPasses { renderEncoder.setTriangleFillMode(.lines) for subMesh in mesh.submeshes { renderEncoder.drawIndexedPrimitivesTracked( - type: subMesh.metalKitSubmesh.primitiveType, - indexCount: subMesh.metalKitSubmesh.indexCount, - indexType: subMesh.metalKitSubmesh.indexType, - indexBuffer: subMesh.metalKitSubmesh.indexBuffer.buffer, - indexBufferOffset: subMesh.metalKitSubmesh.indexBuffer.offset, + type: subMesh.primitiveType, + indexCount: subMesh.indexCount, + indexType: subMesh.indexType, + indexBuffer: subMesh.indexBuffer, + indexBufferOffset: subMesh.indexBufferOffset, category: .other ) } diff --git a/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift b/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift index ac44a9f46..bf2982e66 100644 --- a/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift +++ b/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift @@ -1,3 +1,12 @@ +// +// RenderVertexStreamBinding.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. // // RenderVertexStreamBinding.swift @@ -12,7 +21,7 @@ import Metal import MetalKit import simd -/// Mesh.metalKitMesh.vertexBuffers is laid out in model-descriptor order +/// Mesh.vertexBuffers (the buffers of metalKitMesh.vertexBuffers) is laid out in model-descriptor order /// (ModelPassBufferIndices 0-5) regardless of which pass consumes it; the /// shadow pass sources from that same array but binds to its own slots. /// When the deformation compute pass has produced deformed streams for the @@ -23,32 +32,32 @@ extension MTLRenderCommandEncoder { let deformed = deformedStreams(mesh: mesh, entityId: entityId) setVertexBuffer( - deformed?.positions ?? mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, + deformed?.positions ?? mesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)], offset: 0, index: Int(modelPassVerticesIndex.rawValue) ) setVertexBuffer( - deformed?.normals ?? mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, + deformed?.normals ?? mesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)], offset: 0, index: Int(modelPassNormalIndex.rawValue) ) setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, + mesh.vertexBuffers[Int(modelPassUVIndex.rawValue)], offset: 0, index: Int(modelPassUVIndex.rawValue) ) setVertexBuffer( - deformed?.tangents ?? mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, + deformed?.tangents ?? mesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)], offset: 0, index: Int(modelPassTangentIndex.rawValue) ) setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, + mesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)], offset: 0, index: Int(modelPassJointIdIndex.rawValue) ) setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, + mesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)], offset: 0, index: Int(modelPassJointWeightsIndex.rawValue) ) @@ -65,17 +74,17 @@ extension MTLRenderCommandEncoder { let deformed = deformedStreams(mesh: mesh, entityId: entityId) setVertexBuffer( - deformed?.positions ?? mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, + deformed?.positions ?? mesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)], offset: 0, index: Int(shadowPassModelPositionIndex.rawValue) ) setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, + mesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)], offset: 0, index: Int(shadowPassJointIdIndex.rawValue) ) setVertexBuffer( - mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, + mesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)], offset: 0, index: Int(shadowPassJointWeightsIndex.rawValue) ) diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 7ce73024f..26728cc16 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -199,7 +199,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { BatchingSystem.shared.applyRuntimeBatchingTuning(.macOSBalanced) #endif - Logger.log(message: "Untold Engine Starting. Version 0.21.0") + Logger.log(message: "Untold Engine Starting. Version 0.22.0") } public func initSizeableResources() { @@ -441,7 +441,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { if material.alphaMode == .blend { continue } draws += 1 - triangles += max(0, submesh.metalKitSubmesh.indexCount / 3) + triangles += max(0, submesh.indexCount / 3) } } diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index cd186a348..d9f411402 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -1709,8 +1709,6 @@ public func deserializeScene( uuidToEntityMap[sceneDataEntity.uuid] = entityId setEntityName(entityId: entityId, name: sceneDataEntity.name) - registerTransformComponent(entityId: entityId) - registerSceneGraphComponent(entityId: entityId) if let pickParticipation = sceneDataEntity.pickParticipation { setEntityPickParticipation(entityId: entityId, enabled: pickParticipation) } diff --git a/Sources/UntoldEngine/Shaders/LightShader.metal b/Sources/UntoldEngine/Shaders/LightShader.metal index fd73210bf..a20b64ad6 100644 --- a/Sources/UntoldEngine/Shaders/LightShader.metal +++ b/Sources/UntoldEngine/Shaders/LightShader.metal @@ -285,18 +285,25 @@ float3 computeIBLContribution(texture2d irradianceTexture, ){ //compute ibl ambient contribution - float NoV = max(dot(normalMap.xyz, viewVector), 0.001); + // The angle to the surface, whichever side of it is seen (see specularIBL). + float NoV = min(abs(dot(normalMap.xyz, viewVector)), 1.0); float3 irradiance=diffuseIBL(normalMap.xyz, irradianceTexture, float3(0.0,1.0,0.0),degreesToRadians(iblRotationAngle)); float3 diffuse = irradiance*inBaseColor.rgb; + // What the surface reflects head on: 4 % for a non-metal, its own color for a metal. + // The reflection takes this value as it is: the lookup table already holds how it + // grows when the surface is seen at a slant. float3 f0 = mix(0.04, inBaseColor.rgb, metallic); - float3 F=fresnelSchlick(NoV,f0); - float3 specular=specularIBL(F, roughness, normalMap.xyz, viewVector, specularTexture, iblBRDFTexture,float3(0.0,1.0,0.0),degreesToRadians(iblRotationAngle)); + float3 specular=specularIBL(f0, roughness, normalMap.xyz, viewVector, specularTexture, iblBRDFTexture,float3(0.0,1.0,0.0),degreesToRadians(iblRotationAngle)); - float3 ambient=mix(diffuse,specular,metallic); + // Every surface reflects the environment, a non-metal too: that is the sheen of + // polished plastic, glass or lacquer. What it reflects it does not scatter, so the + // diffuse light gives up that share, and a metal scatters none at all. + float3 reflected = environmentReflectance(f0, roughness, NoV, iblBRDFTexture); + float3 ambient = max(1.0 - reflected, 0.0) * (1.0 - metallic) * diffuse + specular; if(iblParam.applyIBL==false){ ambient=diffuse.rgb; diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.h b/Sources/UntoldEngine/Shaders/ShadersUtils.h index 074a69163..78cccdf83 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.h +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.h @@ -90,6 +90,11 @@ float mod(float x, float y); float selectTextureChannel(float4 sample, int channel); +/// A tangent-space normal with the material's normal strength applied: mixed with the +/// surface's own normal (0, 0, 1) by `strength`, as Blender's Normal Map node does, and +/// brought back to unit length. 1 is the map as authored, 0 the flat surface. +float3 applyNormalStrength(float3 tangentSpaceNormal, float strength); + void transformToLogDepth(thread simd_float4 &position, float far); //BRDF - Great intro: https://boksajak.github.io/files/CrashCourseBRDF.pdf @@ -153,6 +158,8 @@ float4 specularImportanceMap(float2 texCoords, texture2d environmentTextu float4 BRDFIntegrationMap(float roughness, float NoV); +float3 environmentReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap); + // adapted from "Real Shading in Unreal Engine 4", Brian Karis, Epic Games // https://cdn2.unrealengine.com/Resources/files/2013SiggraphPresentationsNotes-26915738.pdf float3 specularIBL(float3 F0 , float roughness, float3 N, float3 V, texture2d specularMap, texture2d brdfMap, float3 rotationAxis, float rotationAngle); diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.metal b/Sources/UntoldEngine/Shaders/ShadersUtils.metal index ddc46ca42..c4b74de0d 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.metal +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.metal @@ -147,6 +147,13 @@ float artistFriendlyF0(float r, float g,float theta){ return 0.5*(rs+rp); } +float3 applyNormalStrength(float3 tangentSpaceNormal, float strength){ + float3 mixed = float3(tangentSpaceNormal.xy * strength, 1.0 + (tangentSpaceNormal.z - 1.0) * strength); + // Beyond 1 the mix can push z through the surface; keep the normal on its own side. + mixed.z = max(mixed.z, 0.001); + return normalize(mixed); +} + // BRDF - If you are new to BRDF implementation, this article provides a great intro: https://boksajak.github.io/files/CrashCourseBRDF.pdf float3 fresnelSchlick(float cosTheta,float3 F0){ @@ -491,17 +498,51 @@ float4 BRDFIntegrationMap(float roughness, float NoV){ } -// adapted from "Real Shading in Unreal Engine 4", Brian Karis, Epic Games -// https://cdn2.unrealengine.com/Resources/files/2013SiggraphPresentationsNotes-26915738.pdf -float3 specularIBL(float3 F0 , float roughness, float3 N, float3 V, texture2d specularMap, texture2d brdfMap, float3 rotationAxis, float rotationAngle) { +// The share of the light around it that a surface reflects: F0 head on (4 % for a +// non-metal, its own color for a metal), more when it is seen at a slant. +float3 environmentReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap) { + // The lookup table is read right at its edges (a surface seen head on, a roughness + // of 1), so it clamps: wrapping there reads the far edge and leaves a white spot. constexpr sampler s(coord::normalized, filter::linear, mip_filter::none, address::clamp_to_edge); + float4 brdfIntegration=brdfMap.sample(s,float2(NoV,roughness)); + + float3 reflectedInOneBounce = F0 * float(brdfIntegration.x) + float(brdfIntegration.y); + + // A rough surface bounces part of the light between its own bumps before it lets it + // go. The table counts a single bounce and takes the rest for lost: up to two thirds + // on the roughest metal, which then comes out too dark. The sum of its two values is + // what a white mirror of this roughness reflects in one bounce; what is missing from + // 1 left on a later bounce, tinted again by the surface each time (Kulla and Conty's + // approximation, in the form Filament uses). An empty table stays an empty result. + float whiteInOneBounce = float(brdfIntegration.x) + float(brdfIntegration.y); + float3 laterBounces = 1.0 + F0 * (1.0 / max(whiteInOneBounce, 0.05) - 1.0); + + return reflectedInOneBounce * laterBounces; +} + +// adapted from "Real Shading in Unreal Engine 4", Brian Karis, Epic Games +// https://cdn2.unrealengine.com/Resources/files/2013SiggraphPresentationsNotes-26915738.pdf +float3 specularIBL(float3 F0 , float roughness, float3 N, float3 V, texture2d specularMap, texture2d brdfMap, float3 rotationAxis, float rotationAngle) { + + // The environment holds one level per roughness, from a mirror image (level 0) to + // the widest blur (the last level): the mip filter is what reads the level asked + // for. Without it every surface reflects level 0, as sharp as a mirror. The map + // goes once around the horizon, so it wraps sideways, and stops at the poles. + constexpr sampler environmentSampler(coord::normalized, + filter::linear, + mip_filter::linear, + s_address::repeat, + t_address::clamp_to_edge); + int mipCount=6; - float NoV = clamp(dot(N, V), 0.0, 1.0); + // The angle to the surface, whichever side of it is seen: a pane seen from behind + // reflects as its front does at that angle, not like a surface seen edge on. + float NoV = min(abs(dot(N, V)), 1.0); float3 R = reflect(-V, N); //Rotate the reflection vector @@ -509,12 +550,9 @@ float3 specularIBL(float3 F0 , float roughness, float3 N, float3 V, texture2d roughnessTexture [[texture(transparencyPassRoughnessTextureIndex)]], texture2d metallicTexture [[texture(transparencyPassMetallicTextureIndex)]], texture2d normalTexture [[texture(transparencyPassNormalTextureIndex)]], + texture2d emissiveTexture [[texture(transparencyPassEmissiveTextureIndex)]], constant bool &hasNormal [[buffer(transparencyPassFragmentHasNormalTextureIndex)]], constant bool &normalIsPackedXY [[buffer(transparencyPassFragmentNormalIsPackedXYIndex)]], constant MaterialParametersUniform &materialParameter [[buffer(transparencyPassFragmentMaterialParameterIndex)]], @@ -48,9 +49,9 @@ fragment float4 fragmentTransparencyShader( st.y = 1.0 - st.y; float4 sampledColor = baseColor.sample(baseColorSampler, st); - float3 tint = all(materialParameter.baseColor.rgb < 0.001) - ? float3(1.0) - : materialParameter.baseColor.rgb; + // See fragmentModelShader: zeros mean "untinted" only for a textured material. + bool isBaseColorUnset = materialParameter.hasTexture.x == 1 && all(materialParameter.baseColor.rgb < 0.001); + float3 tint = isBaseColorUnset ? float3(1.0) : materialParameter.baseColor.rgb; float4 inBaseColor = (materialParameter.hasTexture.x == 1) ? float4(sampledColor.rgb * tint, sampledColor.a * materialParameter.baseColor.a) @@ -62,13 +63,13 @@ fragment float4 fragmentTransparencyShader( // See modelShader.metal's fragmentModelShader for the packed-XY encoding rationale. float4 normalSample = normalTexture.sample(normalSampler, st); - float3 normalMapStandard = normalize(normalSample.rgb); - normalMapStandard = normalMapStandard * 2.0 - 1.0; + float3 normalMapStandard = normalSample.rgb * 2.0 - 1.0; float2 packedXY = normalSample.ga * 2.0 - 1.0; float3 normalMapPackedXY = float3(packedXY, sqrt(saturate(1.0 - dot(packedXY, packedXY)))); float3 normalMap = normalIsPackedXY ? normalMapPackedXY : normalMapStandard; + normalMap = applyNormalStrength(normalMap, materialParameter.normalScale); simd_float3 N = normalize(in.tbNormal); simd_float3 T = normalize(in.tangent.xyz); @@ -172,8 +173,15 @@ fragment float4 fragmentTransparencyShader( ); indirectLighting *= iblParam.ambientIntensity; - float3 finalColor = float3(totalLight.diff) + totalLight.spec + indirectLighting; - // blendEnabled uses premultiplied-alpha blend factors. + // See fragmentModelShader: the emissive color, times the emissive texture when there is one. + float3 emissive = (materialParameter.hasEmissiveTexture == 1) + ? materialParameter.emmissive * emissiveTexture.sample(baseColorSampler, st).rgb + : materialParameter.emmissive; + + float3 finalColor = float3(totalLight.diff) + totalLight.spec + indirectLighting + emissive; + + // blendEnabled uses premultiplied-alpha blend factors. The glow fades with the + // surface like the rest of it: a material half there gives off half the light. return float4(finalColor * inBaseColor.a, inBaseColor.a); } diff --git a/Sources/UntoldEngine/Shaders/iblPreFilterShaders.metal b/Sources/UntoldEngine/Shaders/iblPreFilterShaders.metal index 636258f2c..aef57128b 100644 --- a/Sources/UntoldEngine/Shaders/iblPreFilterShaders.metal +++ b/Sources/UntoldEngine/Shaders/iblPreFilterShaders.metal @@ -36,7 +36,10 @@ fragment IBLFragmentOut fragmentIBLPreFilterShader(VertexCompositeOutput in [[st out.irradiance=diffuseImportanceMap(in.uvCoords, environmentTexture); out.specular=specularImportanceMap(in.uvCoords, environmentTexture, 0.0); - out.brdfMap=BRDFIntegrationMap(1.0-in.uvCoords.y, in.uvCoords.x); + // One row per roughness, from 0 in the first row to 1 in the last, and one column per + // angle of view: specularIBL reads it at (NoV, roughness). The rows used to run the + // other way, so a polished surface took the share of a rough one and the reverse. + out.brdfMap=BRDFIntegrationMap(in.uvCoords.y, in.uvCoords.x); return out; } @@ -107,7 +110,8 @@ fragment IBLFragmentOut fragmentXRIBLCubePreFilterShader(VertexCompositeOutput i IBLFragmentOut out; out.irradiance = diffuseImportanceMapCube(in.uvCoords, environmentTexture); out.specular = specularImportanceMapCube(in.uvCoords, environmentTexture, 0.0); - out.brdfMap = BRDFIntegrationMap(1.0 - in.uvCoords.y, in.uvCoords.x); + // See fragmentIBLPreFilterShader: one row per roughness, as specularIBL reads it. + out.brdfMap = BRDFIntegrationMap(in.uvCoords.y, in.uvCoords.x); return out; } diff --git a/Sources/UntoldEngine/Shaders/modelShader.metal b/Sources/UntoldEngine/Shaders/modelShader.metal index e871e0bf6..d32a4d2e5 100644 --- a/Sources/UntoldEngine/Shaders/modelShader.metal +++ b/Sources/UntoldEngine/Shaders/modelShader.metal @@ -246,6 +246,7 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], texture2d metallicTexture [[texture(modelPassMetallicTextureIndex)]], texture2d normalTexture [[texture(modelPassNormalTextureIndex)]], texture2d heightTexture [[texture(modelPassHeightTextureIndex)]], + texture2d emissiveTexture [[texture(modelPassEmissiveTextureIndex)]], constant bool &hasNormal[[buffer(modelPassFragmentHasNormalTextureIndex)]], constant MaterialParametersUniform &materialParameter [[buffer(modelPassFragmentMaterialParameterIndex)]], sampler baseColorSampler [[sampler(modelPassBaseSamplerIndex)]], @@ -357,11 +358,10 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], ? baseColor.sample(baseColorSampler, sampleUV, gradient2d(stDx, stDy)) : baseColor.sample(baseColorSampler, st, bias(0.25f)); - // Detect if basecolor is all zeros - bool isBaseColorZero = all(materialParameter.baseColor.rgb < 0.001); - - // Fallback to white if base color is zero - float3 tint = isBaseColorZero ? float3(1.0) : materialParameter.baseColor.rgb; + // A textured material whose base color factor was never set (all zeros) shows its + // texture untinted. Without a texture, zero is a color like any other: black. + bool isBaseColorUnset = materialParameter.hasTexture.x == 1 && all(materialParameter.baseColor.rgb < 0.001); + float3 tint = isBaseColorUnset ? float3(1.0) : materialParameter.baseColor.rgb; float4 inBaseColor = (materialParameter.hasTexture.x == 1) ? float4(sampledColor.rgb * tint, sampledColor.a * materialParameter.baseColor.a) @@ -401,13 +401,16 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], // [-1,1]), or astcenc's `-normal` packing (RGB=X, A=Y) used by texbake.py to get a // better error metric for vector data — see NativeTexFlags.normalPackedXY. Z is // reconstructed from the unit-length constraint for the packed case. - float3 normalMapStandard = normalize(normalSample.rgb); - normalMapStandard = normalMapStandard * 2.0 - 1.0; + // [0,1] -> [-1,1] first, unit length after: normalizing the stored color instead + // shortens it before the remap and leaves every normal tilted (a flat texel, (0.5, + // 0.5, 1), came out 22 degrees off). + float3 normalMapStandard = normalSample.rgb * 2.0 - 1.0; float2 packedXY = normalSample.ga * 2.0 - 1.0; float3 normalMapPackedXY = float3(packedXY, sqrt(saturate(1.0 - dot(packedXY, packedXY)))); float3 normalMap = normalIsPackedXY ? normalMapPackedXY : normalMapStandard; + normalMap = applyNormalStrength(normalMap, materialParameter.normalScale); //convert to normal map to world space??? normalMap=(hasNormal==false)?normalize(normalVectorInWorldSpace):normalize(TBN*normalMap); @@ -428,6 +431,16 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], : materialParameter.metallic; metallic=clamp(metallic, 0.0, 1.0); + // The light the surface gives off: the emissive color, times the emissive texture when + // the material has one (the texture carries the color, the factor its strength). It + // shares the base color's sampler: both wrap the same way over the same coordinates. + float4 emissiveSample = hasHeight + ? emissiveTexture.sample(baseColorSampler, sampleUV, gradient2d(stDx, stDy)) + : emissiveTexture.sample(baseColorSampler, st, bias(0.25f)); + float3 emissive = (materialParameter.hasEmissiveTexture == 1) + ? materialParameter.emmissive * emissiveSample.rgb + : materialParameter.emmissive; + float4 color=inBaseColor; gBufferOut.color = float4(color.rgb, passthroughAlpha); @@ -436,7 +449,7 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], // .b/.a carry POM debug data (raw height sample, uv-offset magnitude) for // RenderDebugViewMode.heightDebug / .pomOffsetDebug — see RenderingSystem.swift. gBufferOut.material=float4(roughness, metallic, pomHeightSample, pomOffsetMagnitude); - gBufferOut.emmisive = float4(materialParameter.emmissive, 1.0); + gBufferOut.emmisive = float4(emissive, 1.0); return gBufferOut; diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index cac9f37d6..593d87c15 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -606,6 +606,12 @@ public func setReachIKChains(entityId: EntityID, chains: [ReachIKChainDescriptor /// reach and points at it beyond, the arm extended to `reach` of its /// length. The influence eases to `weight` over `halflife`; a nil position /// eases it back out. +/// +/// An entity has one reach influence: `weight`, `halflife` and `reach` are +/// shared with `setReachIKChainTargets`, and the call made last sets them +/// for every chain. A caller that uses both in the same frame (tracked +/// hands on some chains, this target for the rest) passes the same values +/// to both. public func setReachIKTarget( entityId: EntityID, worldPosition: simd_float3?, @@ -634,6 +640,52 @@ public func setReachIKTarget( } } +/// Gives every reach chain a target of its own, index-aligned with the +/// chains: hands driven by tracking, each going where its tracked hand +/// is. A chain whose entry is nil (or missing) reaches for the shared +/// target of `setReachIKTarget`, or keeps its pose when there is none. +/// The influence eases to `weight` over `halflife`; with no target at all +/// it eases back out. Each target is eased over `targetHalflife`, in its +/// own space; 0 follows it exactly (a source that is already filtered). +/// +/// An entity has one reach influence: `weight`, `halflife` and `reach` are +/// shared with `setReachIKTarget`, and the call made last sets them for +/// every chain, so a caller that mixes the two passes the same values to +/// both. `targetHalflife` is the chains' own: it does not change how the +/// shared target is eased. +public func setReachIKChainTargets( + entityId: EntityID, + targets: [ReachIKChainTarget?], + weight: Float = 1, + halflife: Float = 0.25, + reach: Float = 0.95, + targetHalflife: Float = 0.08 +) { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { + handleError(.noAnimationComponent, entityId) + return + } + + for (_, animationComponent) in animationComponents { + if targets.contains(where: { $0 != nil }) { + animationComponent.reachIK.chainTargets = targets + animationComponent.reachIK.targetWeight = min(max(weight, 0), 1) + } else if animationComponent.reachIK.targetWorld == nil { + // The last targets stay while the influence fades. + animationComponent.reachIK.targetWeight = 0 + } else { + animationComponent.reachIK.chainTargets = targets + } + animationComponent.reachIK.halflife = max(halflife, 0) + animationComponent.reachIK.reach = min(max(reach, 0.05), 1) + animationComponent.reachIK.chainTargetHalflife = max(targetHalflife, 0) + if halflife <= 0 { + animationComponent.reachIK.weight = animationComponent.reachIK.targetWeight + } + } +} + /// Per-chain multipliers on the reach influence, index-aligned with the /// chains: 0 leaves that arm to the pose, 1 gives it the full influence — /// one hand lunging while the other holds back. diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 6bbfcec42..0fecd8b1a 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -2794,6 +2794,7 @@ public class BatchingSystem: @unchecked Sendable { components.append(textureIdentity(material.metallic.texture, material.metallicURL)) components.append(textureIdentity(material.normal.texture, material.normalURL)) components.append(textureIdentity(material.height.texture, material.heightURL)) + components.append(textureIdentity(material.emissive.texture, material.emissiveURL)) // Base color value (important for meshes without textures) components.append(String(format: "%.2f,%.2f,%.2f,%.2f", @@ -2810,6 +2811,12 @@ public class BatchingSystem: @unchecked Sendable { components.append(String(format: "%.2f", material.specular)) components.append(String(format: "%.2f", material.ior)) components.append(String(format: "%.2f", material.stScale)) + components.append(String(format: "%.2f", material.normalScale)) + // What the material gives off: two lamps alike in all but their glow are two materials. + components.append(String(format: "%.2f,%.2f,%.2f", + material.emissiveValue.x, + material.emissiveValue.y, + material.emissiveValue.z)) components.append("\(material.alphaMode.rawValue)") components.append(String(format: "%.2f", material.alphaCutoff)) @@ -2829,6 +2836,7 @@ public class BatchingSystem: @unchecked Sendable { components.append("\(material.hasMetalMap)") components.append("\(material.hasNormalMap)") components.append("\(material.hasHeightMap)") + components.append("\(material.hasEmissiveMap)") // Flags components.append("\(material.interactWithLight)") diff --git a/Sources/UntoldEngine/Systems/CullingSystem.swift b/Sources/UntoldEngine/Systems/CullingSystem.swift index c98470640..ed02366c8 100644 --- a/Sources/UntoldEngine/Systems/CullingSystem.swift +++ b/Sources/UntoldEngine/Systems/CullingSystem.swift @@ -631,6 +631,8 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { var entityAABBContainer: [EntityAABB] = [] entityAABBContainer.reserveCapacity(entities.count) // Pre-allocate to avoid reallocation churn + let smallObjectCulling = SmallObjectCulling.forCurrentFrame() + for entityId in entities { guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { handleError(.noRenderComponent, entityId) @@ -656,6 +658,18 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { continue } + // Objects that would be a pixel or so tall are not worth a draw. + if let smallObjectCulling { + let (center, halfExtent) = worldAABB_CenterExtent( + localMin: localTransformComponent.boundingBox.min, + localMax: localTransformComponent.boundingBox.max, + worldMatrix: worldTransformComponent.space + ) + if smallObjectCulling.culls(center: center, radius: simd_length(halfExtent)) { + continue + } + } + // In XR stereo mode, use a single AABB per entity. The tighter sub-AABBs // produced by segmentation amplify temporal false-culling from 90 Hz head- // tracking drift and cause segment-level occlusion popping. The unsegmented @@ -693,6 +707,10 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { guard count > 0 else { HZBDebugMonitor.shared.recordCull(testedCount: 0, candidateCount: 0, visibleCount: 0, usedHZB: false, optimizedPath: false) + // Nothing to test is a result too: without it the frame would keep drawing the last set. + commandBuffer.addCompletedHandler { _ in + publishVisibleEntities(frame: submitFrameIndex, entities: []) + } return } @@ -958,6 +976,8 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { var entityAABBContainer: [EntityAABB] = [] entityAABBContainer.reserveCapacity(entities.count) // Pre-allocate to avoid reallocation churn + let smallObjectCulling = SmallObjectCulling.forCurrentFrame() + for entityId in entities { guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { handleError(.noRenderComponent, entityId) @@ -983,6 +1003,18 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { continue } + // Objects that would be a pixel or so tall are not worth a draw. + if let smallObjectCulling { + let (center, halfExtent) = worldAABB_CenterExtent( + localMin: localTransformComponent.boundingBox.min, + localMax: localTransformComponent.boundingBox.max, + worldMatrix: worldTransformComponent.space + ) + if smallObjectCulling.culls(center: center, radius: simd_length(halfExtent)) { + continue + } + } + // get object AABB — elongated meshes are split into segments. let segments = makeSegmentedEntityAABBs( localMin: localTransformComponent.boundingBox.min, @@ -1003,6 +1035,10 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { guard count > 0 else { HZBDebugMonitor.shared.recordCull(testedCount: 0, candidateCount: 0, visibleCount: 0, usedHZB: false, optimizedPath: true) + // Nothing to test is a result too: without it the frame would keep drawing the last set. + commandBuffer.addCompletedHandler { _ in + publishVisibleEntities(frame: submitFrameIndex, entities: []) + } return } diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift index 908e99ae5..9ba858515 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift @@ -226,8 +226,6 @@ extension GeometryStreamingSystem { var hlodEntityId: EntityID = .invalid withWorldMutationGate { let id = createEntity() - registerTransformComponent(entityId: id) - registerSceneGraphComponent(entityId: id) setParent(childId: id, parentId: entityId) hlodEntityId = id } @@ -410,8 +408,6 @@ extension GeometryStreamingSystem { var lodEntityId: EntityID = .invalid withWorldMutationGate { let id = createEntity() - registerTransformComponent(entityId: id) - registerSceneGraphComponent(entityId: id) setParent(childId: id, parentId: entityId) lodEntityId = id } @@ -637,8 +633,6 @@ extension GeometryStreamingSystem { var meshEntityId: EntityID = .invalid withWorldMutationGate { let id = createEntity() - registerTransformComponent(entityId: id) - registerSceneGraphComponent(entityId: id) setParent(childId: id, parentId: entityId) meshEntityId = id } diff --git a/Sources/UntoldEngine/Systems/LODSystem.swift b/Sources/UntoldEngine/Systems/LODSystem.swift index 65066e0c9..811d8d041 100644 --- a/Sources/UntoldEngine/Systems/LODSystem.swift +++ b/Sources/UntoldEngine/Systems/LODSystem.swift @@ -29,7 +29,11 @@ public class LODSystem: @unchecked Sendable { public func update(deltaTime: Float) { frameCounter &+= 1 - if LODConfig.shared.enableFadeTransitions { + // One copy for the whole pass: reading the shared configuration takes a lock, + // and a scene can hold tens of thousands of LOD entities. + let config = LODConfig.shared + + if config.enableFadeTransitions { advanceActiveTransitions(deltaTime: deltaTime) } @@ -43,10 +47,10 @@ public class LODSystem: @unchecked Sendable { guard lodShouldRunThisFrame( frameCounter: frameCounter, hasRunOnce: hasRunOnce, - interval: LODConfig.shared.lodUpdateFrameInterval, + interval: config.lodUpdateFrameInterval, cameraPosition: cameraPosition, lastCameraPosition: lastCameraPosition, - displacementThreshold: LODConfig.shared.minimumCameraDisplacementForLODUpdate + displacementThreshold: config.minimumCameraDisplacementForLODUpdate ) else { return } hasRunOnce = true @@ -58,7 +62,7 @@ public class LODSystem: @unchecked Sendable { let entities = queryEntitiesWithComponentIds([lodId, transformId], in: scene) for entityId in entities { - updateEntityLOD(entityId: entityId, cameraPosition: cameraPosition, deltaTime: deltaTime) + updateEntityLOD(entityId: entityId, cameraPosition: cameraPosition, config: config) } } @@ -101,14 +105,14 @@ public class LODSystem: @unchecked Sendable { } } - private func updateEntityLOD(entityId: EntityID, cameraPosition: simd_float3, deltaTime: Float) { + private func updateEntityLOD(entityId: EntityID, cameraPosition: simd_float3, config: LODConfig) { guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { return } // Skip if no LOD levels loaded yet (async loading may still be in progress) guard !lodComponent.lodLevels.isEmpty else { return } if shouldDeferLODSelectionDuringTransition( - fadeTransitionsEnabled: LODConfig.shared.enableFadeTransitions, + fadeTransitionsEnabled: config.enableFadeTransitions, previousLOD: lodComponent.previousLOD ) { return @@ -121,7 +125,8 @@ public class LODSystem: @unchecked Sendable { let desiredLOD = selectLODLevel( distance: distance, lodComponent: lodComponent, - currentLOD: lodComponent.desiredLOD + currentLOD: lodComponent.desiredLOD, + config: config ) lodComponent.desiredLOD = desiredLOD @@ -134,7 +139,7 @@ public class LODSystem: @unchecked Sendable { ) // Apply the LOD (handles transitions, updates render component) - applyLOD(entityId: entityId, newLOD: actualLOD, deltaTime: deltaTime) + applyLOD(entityId: entityId, lodComponent: lodComponent, newLOD: actualLOD, config: config) } /// Resolve desired LOD to actual LOD, falling back if mesh not resident @@ -161,33 +166,31 @@ public class LODSystem: @unchecked Sendable { return lodComponent.currentLOD } - private func selectLODLevel(distance: Float, lodComponent: LODComponent, currentLOD: Int) -> Int { + private func selectLODLevel(distance: Float, lodComponent: LODComponent, currentLOD: Int, config: LODConfig) -> Int { selectLODIndex( levels: lodComponent.lodLevels, distance: distance, currentLOD: currentLOD, forcedLOD: lodComponent.forcedLOD, - lodBias: LODConfig.shared.lodBias, - hysteresis: LODConfig.shared.hysteresis, - globalDistances: LODConfig.shared.lodDistances + lodBias: config.lodBias, + hysteresis: config.hysteresis, + globalDistances: config.lodDistances ) } - private func applyLOD(entityId: EntityID, newLOD: Int, deltaTime _: Float) { - guard let lodComponent = scene.get(component: LODComponent.self, for: entityId), - let renderComponent = scene.get(component: RenderComponent.self, for: entityId) - else { return } - + private func applyLOD(entityId: EntityID, lodComponent: LODComponent, newLOD: Int, config: LODConfig) { let previousLODIndex = lodComponent.currentLOD - // No change needed + // No change needed: the usual case, so it comes before any other lookup. if newLOD == previousLODIndex, lodComponent.previousLOD == nil { return } + guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { return } + withWorldMutationGate { // Handle fade transitions - if LODConfig.shared.enableFadeTransitions { + if config.enableFadeTransitions { if newLOD != previousLODIndex { // Start transition lodComponent.previousLOD = previousLODIndex @@ -203,6 +206,15 @@ public class LODSystem: @unchecked Sendable { // Update render component with new LOD meshes if newLOD >= 0, newLOD < lodComponent.lodLevels.count { + if lodComponent.levelsShareMaterials, !lodComponent.lodLevels[newLOD].mesh.isEmpty { + // The materials are the entity's: the level that leaves keeps those it + // was drawn with (a fade still draws it) and the one that arrives takes them. + let drawnMeshes = renderComponent.mesh + if previousLODIndex != newLOD, previousLODIndex >= 0, previousLODIndex < lodComponent.lodLevels.count { + carryLODMaterials(from: drawnMeshes, to: &lodComponent.lodLevels[previousLODIndex].mesh) + } + carryLODMaterials(from: drawnMeshes, to: &lodComponent.lodLevels[newLOD].mesh) + } let lodLevel = lodComponent.lodLevels[newLOD] // Skip placeholder LODs (empty mesh arrays) if !lodLevel.mesh.isEmpty { @@ -297,12 +309,30 @@ func entityDistanceToCamera(entityId: EntityID, cameraPosition: simd_float3) -> return simd_distance(cameraPosition, simd_float3(worldCenter.x, worldCenter.y, worldCenter.z)) } +/// The share of a level's switch distance that `selectLODIndex` lets the hysteresis reach. +let lodHysteresisDistanceShare: Float = 0.1 + +/// Copies the materials of `source` onto `destination`, mesh for mesh and submesh for +/// submesh. Does nothing unless the two have the same layout. +func carryLODMaterials(from source: [Mesh], to destination: inout [Mesh]) { + guard source.count == destination.count, + zip(source, destination).allSatisfy({ $0.submeshes.count == $1.submeshes.count }) + else { return } + for meshIndex in destination.indices { + for submeshIndex in destination[meshIndex].submeshes.indices { + destination[meshIndex].submeshes[submeshIndex].material = source[meshIndex].submeshes[submeshIndex].material + } + } +} + /// Shared by `LODSystem.selectLODLevel` and `GaussianLODSystem.selectDesiredLOD` — walks /// `levels` in distance order and returns the first whose threshold isn't yet exceeded by /// `distance * lodBias`. Applies `hysteresis` when a candidate level would mean switching to /// higher detail (lower index) than `currentLOD`, so a distance oscillating right at a -/// threshold doesn't flip the selection every re-evaluation. Falls back to -/// `globalDistances[index]` when a level's own `maxDistance` is unset (0). +/// threshold doesn't flip the selection every re-evaluation. The hysteresis never takes more +/// than `lodHysteresisDistanceShare` of a threshold: a fixed distance larger than a level's +/// own switch distance would otherwise keep a small object from ever returning to that level. +/// Falls back to `globalDistances[index]` when a level's own `maxDistance` is unset (0). func selectLODIndex( levels: [some LODDistanceLevel], distance: Float, @@ -328,7 +358,9 @@ func selectLODIndex( continue } - let threshold = index < currentLOD ? baseThreshold - hysteresis : baseThreshold + let threshold = index < currentLOD + ? baseThreshold - min(hysteresis, baseThreshold * lodHysteresisDistanceShare) + : baseThreshold if adjustedDistance <= threshold { return index } diff --git a/Sources/UntoldEngine/Systems/LightingSystem.swift b/Sources/UntoldEngine/Systems/LightingSystem.swift index 8230d0e1b..394b13f5b 100644 --- a/Sources/UntoldEngine/Systems/LightingSystem.swift +++ b/Sources/UntoldEngine/Systems/LightingSystem.swift @@ -212,9 +212,37 @@ public func resetLightPortalAreaLightCache() { LightingSystem.shared.resetLightPortalAreaLightCache() } +/// A light takes the transform and the place in the scene graph of the entity it is made on. +/// `createEntity()` attaches both, and registering them again would replace them: the entity +/// would go back to the origin at scale one and drop out of its hierarchy, with its parent +/// still listing it and its children still pointing to it. Register them only for an entity +/// that has none. +private func ensureLightTransformComponents(entityId: EntityID) { + if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { + registerTransformComponent(entityId: entityId) + } + + if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { + registerSceneGraphComponent(entityId: entityId) + } +} + +/// True when the rotation of the entity is the identity: nothing has rotated it. A new light +/// gets its default orientation only then; an entity that was rotated before it became a +/// light keeps that rotation. +private func hasIdentityRotation(entityId: EntityID) -> Bool { + guard let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) else { + return true + } + + // The vector part of a unit quaternion is zero for the identity, whatever the sign of q. + return simd_length_squared(localTransformComponent.rotation.imag) <= 1.0e-12 +} + private func applyDefaultLightOrientation(entityId: EntityID) { // Engine light emission is defined as local -Z transformed into world space. // Rotate identity lights so the default emission direction points along -Y. + guard hasIdentityRotation(entityId: entityId) else { return } rotateTo(entityId: entityId, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) } @@ -373,16 +401,22 @@ public func setSunElevationAzimuth(entityId: EntityID, elevation: Float, azimuth rotateTo(entityId: entityId, rotation: rotation) } +/// Makes the entity a directional light, and the active one when the scene has none. +/// +/// The entity keeps its position, scale, parent and children. One that has not been rotated +/// becomes a sun straight overhead; one that was rotated keeps its rotation and shines along +/// its local -Z. public func createDirLight(entityId: EntityID) { registerComponent(entityId: entityId, componentType: LightComponent.self) registerComponent(entityId: entityId, componentType: DirectionalLightComponent.self) - registerTransformComponent(entityId: entityId) - registerSceneGraphComponent(entityId: entityId) + ensureLightTransformComponents(entityId: entityId) assignDefaultProceduralLightMesh(entityId: entityId) // Default sun position/brightness for newly-created directional lights, tuned for the // procedural sky background (see getSunElevationAzimuth/setSunElevationAzimuth). - setSunElevationAzimuth(entityId: entityId, elevation: 90.0, azimuth: 0.0) + if hasIdentityRotation(entityId: entityId) { + setSunElevationAzimuth(entityId: entityId, elevation: 90.0, azimuth: 0.0) + } guard let lightComponent = scene.get(component: LightComponent.self, for: entityId) else { handleError(.noLightComponent) @@ -405,11 +439,15 @@ public func createDirLight(entityId: EntityID) { } } +/// Makes the entity a point light. +/// +/// The entity keeps its position, scale, parent and children. One that has not been rotated +/// gets the orientation the other lights start with, local -Z pointing down; one that was +/// rotated keeps its rotation. public func createPointLight(entityId: EntityID) { registerComponent(entityId: entityId, componentType: LightComponent.self) registerComponent(entityId: entityId, componentType: PointLightComponent.self) - registerTransformComponent(entityId: entityId) - registerSceneGraphComponent(entityId: entityId) + ensureLightTransformComponents(entityId: entityId) assignDefaultProceduralLightMesh(entityId: entityId) applyDefaultLightOrientation(entityId: entityId) @@ -431,11 +469,15 @@ public func createPointLight(entityId: EntityID) { } } +/// Makes the entity a spot light. +/// +/// The entity keeps its position, scale, parent and children. One that has not been rotated +/// is turned to shine down; one that was rotated keeps its rotation and shines along its +/// local -Z. public func createSpotLight(entityId: EntityID) { registerComponent(entityId: entityId, componentType: LightComponent.self) registerComponent(entityId: entityId, componentType: SpotLightComponent.self) - registerTransformComponent(entityId: entityId) - registerSceneGraphComponent(entityId: entityId) + ensureLightTransformComponents(entityId: entityId) assignDefaultProceduralLightMesh(entityId: entityId) applyDefaultLightOrientation(entityId: entityId) @@ -461,11 +503,15 @@ public func createSpotLight(entityId: EntityID) { } } +/// Makes the entity an area light: its scale gives the size of the emitting rectangle. +/// +/// The entity keeps its position, scale, parent and children. One that has not been rotated +/// is turned to shine down; one that was rotated keeps its rotation and shines along its +/// local -Z. public func createAreaLight(entityId: EntityID) { registerComponent(entityId: entityId, componentType: LightComponent.self) registerComponent(entityId: entityId, componentType: AreaLightComponent.self) - registerTransformComponent(entityId: entityId) - registerSceneGraphComponent(entityId: entityId) + ensureLightTransformComponents(entityId: entityId) assignDefaultProceduralLightMesh(entityId: entityId) applyDefaultLightOrientation(entityId: entityId) diff --git a/Sources/UntoldEngine/Systems/MemoryBudgetManager.swift b/Sources/UntoldEngine/Systems/MemoryBudgetManager.swift index 1c68fa644..ac189708c 100644 --- a/Sources/UntoldEngine/Systems/MemoryBudgetManager.swift +++ b/Sources/UntoldEngine/Systems/MemoryBudgetManager.swift @@ -95,12 +95,41 @@ struct MemoryEntry { var textureSizeBytes: Int var lastUsedFrame: UInt64 var registrationFrame: UInt64 + /// The allocations behind `meshSizeBytes`, as tokens of the ledger, when the entity + /// was registered by what it holds (`registerMesh(entityId:meshes:)`). Several + /// entities can hold the same allocation, and the totals count it once. Empty for a + /// size registered as a number, which counts as the entity's own. + var meshAllocations: [Int] = [] + /// The same for `textureSizeBytes`. + var textureAllocations: [Int] = [] var totalSize: Int { meshSizeBytes + textureSizeBytes } } +/// A GPU allocation an entity holds: a vertex, index or skinning buffer, or a texture. +struct GPUAllocation { + let object: AnyObject + let bytes: Int +} + +/// An allocation in the ledger, counted once however many entities hold it. +private final class GPUAllocationRecord { + let identifier: ObjectIdentifier + /// Weak, so that the ledger never keeps GPU memory alive, and so that an address + /// handed out again after the allocation died is not mistaken for it. + weak var object: AnyObject? + let bytes: Int + var holders = 0 + + init(_ allocation: GPUAllocation) { + identifier = ObjectIdentifier(allocation.object) + object = allocation.object + bytes = allocation.bytes + } +} + // MARK: - Memory Budget Manager /// Manages GPU memory budget for geometry streaming @@ -172,6 +201,12 @@ public class MemoryBudgetManager: @unchecked Sendable { /// Memory entries indexed by entity ID private var memoryEntries: [EntityID: MemoryEntry] = [:] + /// The allocations entities were registered with, by token, and the token of each + /// allocation that is alive. The placements of a pack model share its buffers and + /// models share textures; each is in the totals once, while any entity holds it. + private var allocationRecords: [Int: GPUAllocationRecord] = [:] + private var allocationTokens: [ObjectIdentifier: Int] = [:] + private var nextAllocationToken: Int = 0 private var auxiliaryMeshBytes: [EntityID: Int] = [:] /// The Gaussian splat frame's shared working set (`GaussianSharedWorkingSet`): one entry for /// the buffers every splat entity compacts into, counted with the geometry. @@ -306,8 +341,7 @@ public class MemoryBudgetManager: @unchecked Sendable { lock.lock() // Remove existing entry if present (handles updates) if let existing = memoryEntries[entityId] { - totalMeshMemory -= existing.meshSizeBytes - totalTextureMemory -= existing.textureSizeBytes + removeFromTotals(existing) } let entry = MemoryEntry( @@ -326,14 +360,116 @@ public class MemoryBudgetManager: @unchecked Sendable { checkThresholdLogging() } - /// Register mesh memory by calculating size from mesh array + /// Register an entity by the buffers and textures its meshes hold. + /// + /// Entities can share them: the placements of a pack model share its vertex and + /// index buffers, and models share the textures of a pack. Each buffer and texture + /// is counted once in the totals, while any registered entity holds it. /// - Parameters: /// - entityId: The entity owning the meshes - /// - meshes: Array of meshes to calculate size from + /// - meshes: The meshes whose GPU allocations the entity holds public func registerMesh(entityId: EntityID, meshes: [Mesh]) { - let meshSize = calculateMeshArrayMemory(meshes) - let textureSize = meshes.reduce(0) { $0 + $1.textureMemorySize } - registerMesh(entityId: entityId, meshSizeBytes: meshSize, textureSizeBytes: textureSize) + registerMesh( + entityId: entityId, + meshAllocations: meshBufferAllocations(of: meshes), + textureAllocations: textureAllocations(of: meshes) + ) + } + + /// `registerMesh(entityId:meshes:)` for allocations that are already listed. + func registerMesh(entityId: EntityID, meshAllocations: [GPUAllocation], textureAllocations: [GPUAllocation]) { + guard enabled else { return } + + lock.lock() + if let existing = memoryEntries[entityId] { + removeFromTotals(existing) + } + + let mesh = hold(meshAllocations) + let texture = hold(textureAllocations) + memoryEntries[entityId] = MemoryEntry( + entityId: entityId, + meshSizeBytes: mesh.size, + textureSizeBytes: texture.size, + lastUsedFrame: currentFrame, + registrationFrame: currentFrame, + meshAllocations: mesh.tokens, + textureAllocations: texture.tokens + ) + totalMeshMemory += mesh.added + totalTextureMemory += texture.added + lock.unlock() + + checkThresholdLogging() + } + + // MARK: - Shared allocations (call with the lock held) + + /// Gives the entity being registered a hold on each of `allocations`, once each. + /// Returns their tokens, their size, and how many of those bytes are new to the + /// ledger: the rest another entity already holds. + private func hold(_ allocations: [GPUAllocation]) -> (tokens: [Int], size: Int, added: Int) { + var tokens: [Int] = [] + var size = 0 + var added = 0 + tokens.reserveCapacity(allocations.count) + for allocation in allocations { + let identifier = ObjectIdentifier(allocation.object) + let record: GPUAllocationRecord + let token: Int + if let known = allocationTokens[identifier], let existing = allocationRecords[known], existing.object === allocation.object { + if tokens.contains(known) { continue } + token = known + record = existing + } else { + token = nextAllocationToken + nextAllocationToken += 1 + record = GPUAllocationRecord(allocation) + allocationRecords[token] = record + allocationTokens[identifier] = token + added += record.bytes + } + record.holders += 1 + tokens.append(token) + size += record.bytes + } + return (tokens, size, added) + } + + /// Lets go of the entity's hold on `tokens`. Returns the bytes that left the ledger + /// with it: those of the allocations no other entity holds. + private func release(_ tokens: [Int]) -> Int { + var removed = 0 + for token in tokens { + guard let record = allocationRecords[token] else { continue } + record.holders -= 1 + guard record.holders <= 0 else { continue } + allocationRecords.removeValue(forKey: token) + if allocationTokens[record.identifier] == token { + allocationTokens.removeValue(forKey: record.identifier) + } + removed += record.bytes + } + return removed + } + + /// Takes an entry out of the totals: its own bytes, and its hold on what it shares. + private func removeFromTotals(_ entry: MemoryEntry) { + totalMeshMemory -= entry.meshAllocations.isEmpty ? entry.meshSizeBytes : release(entry.meshAllocations) + totalTextureMemory -= entry.textureAllocations.isEmpty ? entry.textureSizeBytes : release(entry.textureAllocations) + } + + /// The bytes that evicting the entity alone would free: its own, and the allocations + /// that no other entity holds. + private func bytesHeldAlone(by entry: MemoryEntry) -> Int { + var bytes = entry.meshAllocations.isEmpty ? entry.meshSizeBytes : 0 + bytes += entry.textureAllocations.isEmpty ? entry.textureSizeBytes : 0 + for token in entry.meshAllocations + entry.textureAllocations { + if let record = allocationRecords[token], record.holders == 1 { + bytes += record.bytes + } + } + return bytes } /// Bytes an entity keeps resident alongside its mesh — a splat on a mesh entity @@ -396,8 +532,7 @@ public class MemoryBudgetManager: @unchecked Sendable { guard let entry = memoryEntries.removeValue(forKey: entityId) else { return } - totalMeshMemory -= entry.meshSizeBytes - totalTextureMemory -= entry.textureSizeBytes + removeFromTotals(entry) } // MARK: - Usage Tracking @@ -565,7 +700,10 @@ public class MemoryBudgetManager: @unchecked Sendable { lock.unlock() return } - totalTextureMemory -= entry.textureSizeBytes + // The textures streamed in are the entity's own count from here on: it lets go + // of the ones it was registered with. + totalTextureMemory -= entry.textureAllocations.isEmpty ? entry.textureSizeBytes : release(entry.textureAllocations) + entry.textureAllocations = [] entry.textureSizeBytes = newSizeBytes entry.lastUsedFrame = currentFrame totalTextureMemory += newSizeBytes @@ -575,12 +713,14 @@ public class MemoryBudgetManager: @unchecked Sendable { checkThresholdLogging() } - /// Get memory size for an entity (if tracked) + /// Get memory size for an entity (if tracked): the bytes that evicting it would + /// free. Buffers and textures that another registered entity also holds are not in + /// it, since they stay when this entity goes. public func getMemorySize(for entityId: EntityID) -> Int? { lock.lock() defer { lock.unlock() } - return memoryEntries[entityId]?.totalSize + return memoryEntries[entityId].map(bytesHeldAlone) } /// Get last used frame for an entity @@ -627,7 +767,7 @@ public class MemoryBudgetManager: @unchecked Sendable { let sorted = memoryEntries.values .sorted { $0.lastUsedFrame < $1.lastUsedFrame } .prefix(count) - .map { (entityId: $0.entityId, sizeBytes: $0.totalSize) } + .map { (entityId: $0.entityId, sizeBytes: bytesHeldAlone(by: $0)) } return Array(sorted) } @@ -652,11 +792,23 @@ public class MemoryBudgetManager: @unchecked Sendable { // Sort by last used frame (oldest first) let sorted = memoryEntries.values.sorted { $0.lastUsedFrame < $1.lastUsedFrame } + // What a shared allocation frees depends on who went before: it goes with the + // last of the entities that hold it. + var holdersLeft: [Int: Int] = [:] var candidates: [EntityID] = [] for entry in sorted { if memoryToFree <= 0 { break } candidates.append(entry.entityId) - memoryToFree -= entry.totalSize + memoryToFree -= entry.meshAllocations.isEmpty ? entry.meshSizeBytes : 0 + memoryToFree -= entry.textureAllocations.isEmpty ? entry.textureSizeBytes : 0 + for token in entry.meshAllocations + entry.textureAllocations { + guard let record = allocationRecords[token] else { continue } + let left = (holdersLeft[token] ?? record.holders) - 1 + holdersLeft[token] = left + if left == 0 { + memoryToFree -= record.bytes + } + } } return candidates @@ -718,6 +870,8 @@ public class MemoryBudgetManager: @unchecked Sendable { defer { lock.unlock() } memoryEntries.removeAll() + allocationRecords.removeAll() + allocationTokens.removeAll() auxiliaryMeshBytes.removeAll() gaussianWorkingSetBytes = 0 totalMeshMemory = 0 diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index f70bef05a..c9ad0f947 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -12,6 +12,7 @@ import CShaderTypes import Foundation import MetalKit +import os @inline(__always) private func enforceRegistrationMainActor() { @@ -252,6 +253,8 @@ private func registerComponentCleanupHandlers() { } } +/// Creates an entity that already has its transform (local and world) and scene graph +/// components. Registering them again replaces them with new ones at their defaults. public func createEntity() -> EntityID { enforceRegistrationMainActor() globalEntityCounter += 1 @@ -597,8 +600,14 @@ private func ensureUntoldNodeComponents(entityId: EntityID) { } } -func makeMeshes(from node: RuntimeAssetNode) -> [Mesh] { +/// `withMaterials: false` builds the geometry alone, for meshes that take their +/// materials from elsewhere (the levels of a LOD chain use the model's). +func makeMeshes(from node: RuntimeAssetNode, withMaterials: Bool = true) -> [Mesh] { node.primitives.compactMap { primitive -> Mesh? in + var primitive = primitive + if !withMaterials { + primitive.material = nil + } guard var mesh = Mesh.makeMesh(from: primitive, device: renderInfo.device) else { return nil } @@ -611,10 +620,10 @@ func makeMeshes(from node: RuntimeAssetNode) -> [Mesh] { /// Pre-build Metal meshes for all renderable nodes in a runtime asset. /// Returns a map of nodeID → [Mesh] built via makeMeshes() — pure MTLBuffer allocation, /// no ECS access. Safe to call outside withWorldMutationGate. -func prebuildNodeMeshes(from nodes: [RuntimeAssetNode]) -> [UInt32: [Mesh]] { +func prebuildNodeMeshes(from nodes: [RuntimeAssetNode], withMaterials: Bool = true) -> [UInt32: [Mesh]] { var result: [UInt32: [Mesh]] = [:] for node in nodes where !node.primitives.isEmpty { - let meshes = makeMeshes(from: node) + let meshes = makeMeshes(from: node, withMaterials: withMaterials) if !meshes.isEmpty { result[node.id] = meshes } @@ -910,7 +919,9 @@ private func registerUntoldRuntimeAsset( filename: String, withExtension: String, assetName: String? = nil, - prebuiltMeshes: [UInt32: [Mesh]] = [:] + prebuiltMeshes: [UInt32: [Mesh]] = [:], + lodLevels: [PackLODLevelMeshes] = [], + lodPlacementScale: Float = 1 ) -> Bool { guard !runtimeAsset.nodes.isEmpty else { handleError(.assetDataMissing, filename) @@ -1079,13 +1090,22 @@ private func registerUntoldRuntimeAsset( continue } - _ = registerUntoldNodePayload( + let didRegisterPayload = registerUntoldNodePayload( entityId: targetEntityId, node: node, nodesByID: nodesByID, url: url, prebuiltMeshes: prebuiltMeshes[node.id] ) + if didRegisterPayload, !lodLevels.isEmpty { + registerPackLODLevels( + entityId: targetEntityId, + node: node, + modelURL: url, + worldRadius: boundingRadius(of: runtimeAsset.worldBounds) * lodPlacementScale, + levels: lodLevels + ) + } } // Register animation clips embedded in the asset (e.g. redplayer.untold walk/run cycles). @@ -1371,6 +1391,63 @@ private func resolveAssetFilenameExtension( return ((filename as NSString).deletingPathExtension, embeddedExtension) } +/// Resolves a resource URL, probing between two candidate extensions when the caller didn't +/// say which one they meant -- mesh (`.untoldpack`/`.untold`), animation +/// (`.untoldanim`/`.untold`), and Gaussian splats (`.untoldgs`/`.ply`) all share this. +/// +/// When `ext` is non-empty -- an explicit `withExtension` or one embedded in +/// `filename` via `resolveAssetFilenameExtension` -- resolves exactly that file, +/// same as before this helper existed. When `ext` is empty (no extension given +/// anywhere), tries each of `probeExtensions` in order and returns the first +/// that exists, so `setEntityMeshAsync(entityId:, filename: "Bedroom")` works +/// whether "Bedroom" exported as a single `.untold` or a multi-model +/// `.untoldpack`, without the caller needing to know which ahead of time. +/// +/// The exporter keeps a given base name single-owner -- re-exporting after a +/// scene's model count changes removes the stale `.untold`/`.untoldpack` at +/// that stem (see UsingUntoldEngineCLI.md) -- so more than one candidate +/// existing here means something outside the normal export flow put both +/// there; that's worth a log line since the choice would otherwise be silent. +func resolveProbedAssetURL( + filename: String, + ext: String, + probeExtensions: [String], + subResource: String? = nil +) -> URL? { + guard ext.isEmpty else { + return LoadingSystem.shared.resourceURL(forResource: filename, withExtension: ext, subResource: subResource) + } + let matches = probeExtensions.compactMap { candidate -> (String, URL)? in + LoadingSystem.shared.resourceURL(forResource: filename, withExtension: candidate, subResource: subResource) + .map { (candidate, $0) } + } + if matches.count > 1 { + Logger.logWarning( + message: "[RegistrationSystem] '\(filename)' resolves to both ." + + matches.map(\.0).joined(separator: " and .") + + "; using .\(matches[0].0). Pass withExtension explicitly to choose." + ) + } + return matches.first?.1 +} + +/// Like `resolveProbedAssetURL`, but also returns the concrete extension that won -- for +/// callers that must persist a filename/extension pair for a later, deferred load (e.g. +/// `StreamingComponent.assetExtension`) rather than resolving a URL once and using it +/// immediately. Persisting the resolved extension means the deferred load never has to probe +/// again or risk re-resolving to a different file if one is added later. +func resolveProbedAsset( + filename: String, + ext: String, + probeExtensions: [String], + subResource: String? = nil +) -> (url: URL, extension: String)? { + guard let url = resolveProbedAssetURL(filename: filename, ext: ext, probeExtensions: probeExtensions, subResource: subResource) else { + return nil + } + return (url, ext.isEmpty ? url.pathExtension : ext) +} + /// Loads a standalone .cube color-grade LUT and applies it immediately, fully /// independent of any scene/manifest -- unlike the colorGradeLUT reference /// installed by loadSceneAuthored, this can point at any .cube file (hand @@ -1521,10 +1598,10 @@ public func setEntityMesh( assetName: String? = nil ) { let (filename, withExtension) = resolveAssetFilenameExtension(filename, withExtension) - guard let url = LoadingSystem.shared.resourceURL( - forResource: filename, - withExtension: withExtension, - subResource: nil + guard let url = resolveProbedAssetURL( + filename: filename, + ext: withExtension, + probeExtensions: ["untoldpack", "untold"] ) else { handleError(.filenameNotFound, filename) loadFallbackMesh(entityId: entityId, filename: filename) @@ -1569,6 +1646,35 @@ public func setEntityMeshAsync( streamingPolicy: MeshStreamingPolicy = .immediate, blockRenderLoop: Bool = true, completion: ((Bool) -> Void)? = nil +) { + loadEntityMeshAsync( + entityId: entityId, + filename: filename, + withExtension: withExtension, + assetName: assetName, + streamingPolicy: streamingPolicy, + blockRenderLoop: blockRenderLoop, + sharedBuilds: nil, + completion: completion + ) +} + +/// setEntityMeshAsync's implementation. `sharedBuilds` lets the models of one +/// `.untoldpack` that point at the same `.untold` parse it and build its GPU meshes +/// once (see UntoldBuildCache). It applies to whole files loaded at once, as the pack +/// loader asks for them: with an `assetName` or a streaming policy other than +/// `.immediate` the load is not shared. `lodChain` is the model's LOD chain, whose +/// levels are built the same way and registered on the entities the model creates. +func loadEntityMeshAsync( + entityId: EntityID, + filename: String, + withExtension: String?, + assetName: String?, + streamingPolicy: MeshStreamingPolicy, + blockRenderLoop: Bool, + sharedBuilds: UntoldBuildCache?, + lodChain: PackLODChain? = nil, + completion: ((Bool) -> Void)? ) { let (filename, withExtension) = resolveAssetFilenameExtension(filename, withExtension) let completionBox = completion.map { BoolCompletionBox(callback: $0) } @@ -1580,7 +1686,11 @@ public func setEntityMeshAsync( await AssetLoadingState.shared.startLoading(entityId: entityId, filename: filename, blockRenderLoop: blockRenderLoop) // Get URL - guard let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { + guard let url = resolveProbedAssetURL( + filename: filename, + ext: withExtension, + probeExtensions: ["untoldpack", "untold"] + ) else { handleError(.filenameNotFound, filename) withWorldMutationGate { loadFallbackMesh(entityId: entityId, filename: filename) @@ -1629,7 +1739,26 @@ public func setEntityMeshAsync( } if RuntimeAssetSource.infer(from: url).kind == .untold { - guard let runtimeAsset = loadUntoldRuntimeAsset(url: url) else { + // Placements of one pack that share this file reuse its parsed asset and its + // GPU meshes: each entity registers its own copy of the Mesh values, which + // share the buffers and textures. + // + // A shared build is the whole file, built at once. A load of one named node + // (`assetName`) or one that streams its meshes in later is neither, so it + // goes the way it always did, on its own, even when the caller passes + // `sharedBuilds`. The pack loader asks for whole files loaded at once, which + // is why every one of its placements shares. A caller that needs shared + // builds for named nodes or streamed meshes has to teach the cache about + // them first: this test is where that shows. + let usesSharedBuild = sharedBuilds != nil && assetName == nil && streamingPolicy == .immediate + var sharedBuild: UntoldBuild? + if usesSharedBuild, let sharedBuilds { + sharedBuild = await sharedBuilds.build(for: url) { + guard let runtimeAsset = loadUntoldRuntimeAsset(url: url) else { return nil } + return UntoldBuild(runtimeAsset: runtimeAsset, prebuiltMeshes: prebuildNodeMeshes(from: runtimeAsset.nodes)) + } + } + guard let runtimeAsset = usesSharedBuild ? sharedBuild?.runtimeAsset : loadUntoldRuntimeAsset(url: url) else { withWorldMutationGate { loadFallbackMesh(entityId: entityId, filename: filename) } @@ -1638,6 +1767,25 @@ public func setEntityMeshAsync( return } + // The levels of the model's LOD chain: geometry alone, since every level is + // drawn with the model's materials. A level that fails to load is left out + // and the others still switch at their own sizes. + var lodLevels: [PackLODLevelMeshes] = [] + if usesSharedBuild, let sharedBuilds, let lodChain { + for level in lodChain.levels { + let build = await sharedBuilds.build(for: level.url) { + guard let levelAsset = loadUntoldRuntimeAsset(url: level.url) else { return nil } + return UntoldBuild( + runtimeAsset: levelAsset, + prebuiltMeshes: prebuildNodeMeshes(from: levelAsset.nodes, withMaterials: false) + ) + } + if let build { + lodLevels.append(PackLODLevelMeshes(url: level.url, screenSize: level.screenSize, meshesByNode: build.prebuiltMeshes)) + } + } + } + // OCC is only valid for whole-asset loads. Named-node loads always full-load. let useOCC: Bool if assetName != nil { @@ -1666,7 +1814,7 @@ public func setEntityMeshAsync( // Keeping this inside withWorldMutationGate was the root cause of 30-40ms gate // holds during HLOD and LOD tile registration, which blocked the main thread. // OCC path builds meshes separately (CPU→GPU upload), so no pre-build needed there. - let prebuiltMeshes: [UInt32: [Mesh]] = useOCC ? [:] : prebuildNodeMeshes(from: runtimeAsset.nodes) + let prebuiltMeshes: [UInt32: [Mesh]] = useOCC ? [:] : (sharedBuild?.prebuiltMeshes ?? prebuildNodeMeshes(from: runtimeAsset.nodes)) let didLoad: Bool = withWorldMutationGate { if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { @@ -1695,7 +1843,9 @@ public func setEntityMeshAsync( filename: filename, withExtension: withExtension, assetName: assetName, - prebuiltMeshes: prebuiltMeshes + prebuiltMeshes: prebuiltMeshes, + lodLevels: lodLevels, + lodPlacementScale: lodChain?.placementScale ?? 1 ) } @@ -2311,6 +2461,23 @@ public struct UntoldPackData: Decodable { public let formatVersion: Int public let sourceAsset: String? public let models: [UntoldPackModelEntry] + /// The LOD chain of each model that has one, by the model's `path`: its simplified + /// levels from the finest (see `UntoldMeshLODCooker` in the UntoldEngineMeshCook + /// module). Nil in a pack cooked without them. + public let lodChains: [String: [UntoldPackLODLevelEntry]]? +} + +/// One simplified level of a pack model: a `.untold` with the model's entities and +/// materials and fewer triangles, next to the model. +public struct UntoldPackLODLevelEntry: Decodable, Sendable { + /// The level's file, relative to the manifest like the model's. + public let path: String + /// The level is detailed enough once the model's bounding sphere covers at most + /// this share of the viewport height (1 is the whole height). + public let screenSize: Float + public let triangles: Int? + /// The largest deviation from the model, in model units. + public let error: Float? } /// Reads and decodes a `.untoldpack` manifest from a local file URL. @@ -2326,6 +2493,15 @@ public func loadUntoldPack(url: URL) -> UntoldPackData? { return pack } +/// The largest of the scales `matrix` applies along its three axes. +func largestAxisScale(of matrix: simd_float4x4) -> Float { + max( + simd_length(simd_make_float3(matrix.columns.0)), + simd_length(simd_make_float3(matrix.columns.1)), + simd_length(simd_make_float3(matrix.columns.2)) + ) +} + private func decomposeTRS(_ matrix: simd_float4x4) -> (position: simd_float3, rotation: simd_quatf, scale: simd_float3) { let position = simd_make_float3(matrix.columns.3) var col0 = simd_make_float3(matrix.columns.0) @@ -2410,6 +2586,239 @@ public func createUntoldScene(fromPackAt packURL: URL, savingTo sceneURL: URL) - } } +/// The LOD chain of a pack model, as one placement of it needs it. +struct PackLODChain { + struct Level { + let url: URL + let screenSize: Float + } + + /// The simplified levels, finest first. + let levels: [Level] + /// The largest scale the placement gives the model, the pack's root included. + let placementScale: Float +} + +/// One level of a chain built into GPU meshes, by the node of the model they belong to. +struct PackLODLevelMeshes { + let url: URL + let screenSize: Float + let meshesByNode: [UInt32: [Mesh]] +} + +/// Half the diagonal of `bounds`: the radius of the sphere around a model. +func boundingRadius(of bounds: RuntimeAABB) -> Float { + let radius = simd_length(bounds.max - bounds.min) * 0.5 + return radius.isFinite ? radius : 0 +} + +/// The distance at which a sphere of `radius` covers `screenSize` of the viewport +/// height (1 is the whole height) under a vertical field of view of `fovYDegrees`. +func lodSwitchDistance(radius: Float, screenSize: Float, fovYDegrees: Float) -> Float { + let halfHeightPerUnitDistance = tan(degreesToRadians(degrees: fovYDegrees) * 0.5) + guard radius > 0, screenSize > 0, halfHeightPerUnitDistance > 0 else { + return .greatestFiniteMagnitude + } + return min(radius / (screenSize * halfHeightPerUnitDistance), .greatestFiniteMagnitude) +} + +/// Gives the entity of a pack model's node the levels of the model's LOD chain, after +/// its own meshes (level 0). The meshes of a level pair with the node's by position +/// and take their materials, so a level is the same surface with fewer triangles. +/// +/// Each level's screen size becomes a switch distance for this placement: the distance +/// at which the model's bounding sphere (`worldRadius`) covers that share of the +/// viewport under the engine's field of view. A small prop and a large tree with the +/// same chain therefore switch at distances that suit each. +private func registerPackLODLevels( + entityId: EntityID, + node: RuntimeAssetNode, + modelURL: URL, + worldRadius: Float, + levels: [PackLODLevelMeshes] +) { + guard worldRadius > 0, let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { + return + } + let modelMeshes = renderComponent.mesh + + var usable: [(screenSize: Float, url: URL, meshes: [Mesh])] = [] + for level in levels { + guard var meshes = level.meshesByNode[node.id], + meshes.count == modelMeshes.count, + zip(meshes, modelMeshes).allSatisfy({ $0.submeshes.count == $1.submeshes.count }) + else { continue } + for meshIndex in meshes.indices { + for submeshIndex in meshes[meshIndex].submeshes.indices { + meshes[meshIndex].submeshes[submeshIndex].material = modelMeshes[meshIndex].submeshes[submeshIndex].material + } + meshes[meshIndex].localSpace = modelMeshes[meshIndex].localSpace + meshes[meshIndex].worldSpace = modelMeshes[meshIndex].worldSpace + } + usable.append((level.screenSize, level.url, meshes)) + } + guard let first = usable.first else { return } + + // A level's maxDistance is where the next one takes over; the last has no end. + var lodLevels: [LODLevel] = [] + var previousDistance = lodSwitchDistance(radius: worldRadius, screenSize: first.screenSize, fovYDegrees: fov) + lodLevels.append(LODLevel(mesh: modelMeshes, maxDistance: previousDistance, url: modelURL, assetName: node.name)) + for (index, level) in usable.enumerated() { + var maxDistance = Float.greatestFiniteMagnitude + if index + 1 < usable.count { + // Never closer than the level before, whatever the manifest says. + maxDistance = max( + lodSwitchDistance(radius: worldRadius, screenSize: usable[index + 1].screenSize, fovYDegrees: fov), + previousDistance + ) + previousDistance = maxDistance + } + lodLevels.append(LODLevel( + mesh: level.meshes, + maxDistance: maxDistance, + screenPercentage: level.screenSize, + url: level.url, + assetName: node.name + )) + } + + configureLODComponent(entityId: entityId, lodLevels: lodLevels, activeLODIndex: 0) + scene.get(component: LODComponent.self, for: entityId)?.levelsShareMaterials = true +} + +/// A `.untold` parsed and built into GPU meshes, ready for any number of entities to +/// register copies of. +final class UntoldBuild: @unchecked Sendable { + let runtimeAsset: RuntimeAsset + let prebuiltMeshes: [UInt32: [Mesh]] + + init(runtimeAsset: RuntimeAsset, prebuiltMeshes: [UInt32: [Mesh]]) { + self.runtimeAsset = runtimeAsset + self.prebuiltMeshes = prebuiltMeshes + } +} + +/// The `.untold` builds of one pack load, keyed by file. A `.untoldpack` can place the +/// same `.untold` hundreds of times (the exporter writes a repeated model once), and +/// each placement used to read the file, decode it and allocate its own GPU buffers. +/// `Mesh` is a value type whose buffers and textures are references, so every entity +/// registers its own copy of the built meshes, sharing the GPU memory, while material +/// edits and skins stay per entity. +/// +/// Single-flight: the first caller for a file builds it; callers arriving meanwhile +/// suspend until it is ready instead of building it again. A build that fails is tried +/// a second time at once, so that a passing read error does not cost every placement +/// of the file its model; a file that fails twice is remembered as failed, and its +/// other placements fall back without reading it again. +/// +/// The table of states lives inside an unfair lock, so it can only be read or changed +/// while the lock is held, and that is for a few instructions at a time. The lock is +/// never held while a file is built, so different files build at the same time, and +/// never while a waiting caller is resumed. +final class UntoldBuildCache: Sendable { + private typealias Waiter = CheckedContinuation + + /// Where the build of one file stands. + private enum State { + /// A caller is building it; these callers wait for the result. + case building(waiters: [Waiter]) + /// Built, or failed twice (nil). + case finished(UntoldBuild?) + } + + /// What a caller that asks for a file has to do. + private enum Claim { + case take(UntoldBuild?) + case wait + case build + } + + /// What became of a caller that went to wait for a build. + private enum Arrival { + /// It is queued, and will be resumed with the result. + case queued + /// The build finished before it could queue. + case finished(UntoldBuild?) + } + + /// The state of every file asked for so far, by its standardized path. + private let states = OSAllocatedUnfairLock<[String: State]>(initialState: [:]) + + func build(for url: URL, make: () -> UntoldBuild?) async -> UntoldBuild? { + let key = url.standardizedFileURL.path + switch claim(key) { + case let .take(build): + return build + case .wait: + return await withCheckedContinuation { continuation in + if case let .finished(build) = queue(continuation, for: key) { + continuation.resume(returning: build) + } + } + case .build: + let result = make() ?? make() + // Resumed here, after `finish` has let go of the lock. + for waiter in finish(key, result) { + waiter.resume(returning: result) + } + return result + } + } + + /// Whether the file is built, being built by another caller, or now this caller's to build. + private func claim(_ key: String) -> Claim { + states.withLock { states in + switch states[key] { + case let .finished(build): + return .take(build) + case .building: + return .wait + case nil: + states[key] = .building(waiters: []) + return .build + } + } + } + + /// Queues a caller behind the build in progress, unless it finished since `claim`. + private func queue(_ waiter: Waiter, for key: String) -> Arrival { + states.withLock { states in + switch states[key] { + case let .building(waiters): + states[key] = .building(waiters: waiters + [waiter]) + return .queued + case let .finished(build): + return .finished(build) + case nil: + // Not reached: a caller waits only for a build that was claimed, and a + // claimed build ends in `finished`. + return .finished(nil) + } + } + } + + /// Records the result and hands back the callers waiting for it. + private func finish(_ key: String, _ result: UntoldBuild?) -> [Waiter] { + states.withLock { states in + defer { states[key] = .finished(result) } + if case let .building(waiters) = states[key] { + return waiters + } + return [] + } + } + + /// The number of distinct files built so far (for tests and diagnostics). + var buildCount: Int { + states.withLock { states in + states.values.reduce(0) { count, state in + if case .finished = state { return count + 1 } + return count + } + } + } +} + /// Drives loadEntityFromPack's model loads through a bounded concurrency window /// instead of firing every model's setEntityMeshAsync at once. /// @@ -2425,17 +2834,29 @@ public func createUntoldScene(fromPackAt packURL: URL, savingTo sceneURL: URL) - private final class PackLoadDispatcher: @unchecked Sendable { private let lock = NSLock() private let models: [UntoldPackModelEntry] + private let lodChains: [String: [UntoldPackLODLevelEntry]] private let packDir: URL private let rootEntityId: EntityID private let completionBox: BoolCompletionBox? private var nextIndex = 0 private var remaining: Int private var overallSuccess = true + /// One parse and one GPU build per distinct .untold for the duration of the pack + /// load; dropped with the dispatcher, after which the entities' copies keep the + /// shared buffers alive. + private let builds = UntoldBuildCache() private static let maxConcurrentLoads = 8 - init(models: [UntoldPackModelEntry], packDir: URL, rootEntityId: EntityID, completionBox: BoolCompletionBox?) { + init( + models: [UntoldPackModelEntry], + lodChains: [String: [UntoldPackLODLevelEntry]], + packDir: URL, + rootEntityId: EntityID, + completionBox: BoolCompletionBox? + ) { self.models = models + self.lodChains = lodChains self.packDir = packDir self.rootEntityId = rootEntityId self.completionBox = completionBox @@ -2466,15 +2887,31 @@ private final class PackLoadDispatcher: @unchecked Sendable { let withExtension = modelURL.pathExtension let (position, rotation, scale) = decomposeTRS(model.transform) let rootEntityId = rootEntityId + let lodLevels = (lodChains[model.path] ?? []).map { + PackLODChain.Level(url: packDir.appendingPathComponent($0.path), screenSize: $0.screenSize) + } withWorldMutationGate { + var lodChain: PackLODChain? + if !lodLevels.isEmpty { + // The size of the placement in the world decides where its levels switch. + let rootScale = scene.get(component: WorldTransformComponent.self, for: rootEntityId).map { largestAxisScale(of: $0.space) } ?? 1 + lodChain = PackLODChain(levels: lodLevels, placementScale: simd_reduce_max(simd_abs(scale)) * rootScale) + } let childId = createEntity() - registerTransformComponent(entityId: childId) - registerSceneGraphComponent(entityId: childId) setEntityName(entityId: childId, name: displayName) setParent(childId: childId, parentId: rootEntityId) - setEntityMeshAsync(entityId: childId, filename: modelPath, withExtension: withExtension) { success in + loadEntityMeshAsync( + entityId: childId, + filename: modelPath, + withExtension: withExtension, + assetName: nil, + streamingPolicy: .immediate, + blockRenderLoop: true, + sharedBuilds: builds, + lodChain: lodChain + ) { success in withWorldMutationGate { translateTo(entityId: childId, position: position) scaleTo(entityId: childId, scale: scale) @@ -2549,7 +2986,13 @@ private func loadEntityFromPack( } let packDir = packURL.deletingLastPathComponent() - let dispatcher = PackLoadDispatcher(models: pack.models, packDir: packDir, rootEntityId: rootEntityId, completionBox: completionBox) + let dispatcher = PackLoadDispatcher( + models: pack.models, + lodChains: pack.lodChains ?? [:], + packDir: packDir, + rootEntityId: rootEntityId, + completionBox: completionBox + ) dispatcher.start() } @@ -2723,6 +3166,14 @@ private func manifestColorGradeLUT( /// The caller is responsible for creating `rootEntityId` via `createEntity()` before /// calling this function, and for managing its lifetime. To replace a streamed scene, /// destroy the old root (cascades to all tile stubs), then call this with a new root. +/// +/// The root keeps its place in the scene graph: its parent, and the entities already +/// parented under it. Its own position, rotation and scale are reset to identity when +/// the stubs are registered, because the manifest's tile bounds are world-space values +/// that do not follow the root; a warning is logged when that discards a transform, and +/// when an ancestor still keeps the root away from identity. To place a streamed scene, +/// use `translateSceneTo`, `rotateSceneToYaw` and `scaleSceneTo`. +/// /// A manifest `colorLUT` is installed automatically because it is scene-wide. /// Scene-authored lights/cameras remain opt-in; call `loadSceneAuthored(url:)` /// explicitly when you want those entities in the current scene. @@ -2870,7 +3321,9 @@ public func loadTiledScene( /// streaming system as the camera approaches each tile. /// /// The caller is responsible for creating `rootEntityId` via `createEntity()` before -/// calling this function, and for managing its lifetime. A manifest `colorLUT` +/// calling this function, and for managing its lifetime. The root keeps its parent and +/// the entities already parented under it; its own transform is reset to identity, as +/// described on `setEntityStreamScene(entityId:manifest:)`. A manifest `colorLUT` /// is installed automatically. Scene-authored lights/cameras remain opt-in /// through `loadSceneAuthored(url:)`. /// @@ -3008,6 +3461,53 @@ public func loadTiledScene( } } +/// Puts the root of a tiled scene back at the identity of its parent's space, and says +/// so when that discards a transform or cannot make the world transform the identity. +/// +/// The streaming and culling checks read the manifest's tile bounds as world-space values, +/// so a tiled scene only streams correctly while the world transform of its root is the +/// identity. The root's own position, rotation and scale are reset through the transform +/// API, which moves the entities already parented under it at once. A transform that +/// comes from an ancestor is not the root's to change and can only be reported. +private func resetTiledSceneRootTransform(rootEntityId: EntityID) { + guard let local = scene.get(component: LocalTransformComponent.self, for: rootEntityId) else { + return + } + + let isMoved = local.position != .zero + // The vector part of a quaternion is zero for the identity, whatever the sign of q. + let isRotated = simd_length_squared(local.rotation.imag) > 1.0e-12 + let isScaled = local.scale != .one + + // These warnings go to the general category: TileStreaming is off by default, and a + // caller whose placement is dropped or whose scene shows displaced has to see why. + if isMoved || isRotated || isScaled { + Logger.logWarning( + message: "[setEntityStreamScene] Root '\(getEntityName(entityId: rootEntityId))' had a transform of its own (position \(local.position), scale \(local.scale)\(isRotated ? ", rotated" : "")), which was reset to identity: tile bounds are world-space values and do not follow the root. Place a streamed scene with translateSceneTo / rotateSceneToYaw / scaleSceneTo." + ) + if isMoved { + translateTo(entityId: rootEntityId, position: .zero) + } + if isRotated { + applyAxisRotations(entityId: rootEntityId, axis: .zero) + } + if isScaled { + scaleTo(entityId: rootEntityId, scale: .one) + } + } + + guard let world = scene.get(component: WorldTransformComponent.self, for: rootEntityId), + transformsApproximatelyEqual(world.space, .identity) == false + else { + return + } + + let parentName = getEntityParent(entityId: rootEntityId).map { getEntityName(entityId: $0) } ?? "none" + Logger.logWarning( + message: "[setEntityStreamScene] Root '\(getEntityName(entityId: rootEntityId))' is not at identity in world space because of its ancestors (parent '\(parentName)'). Tile bounds are world-space values and do not follow them: the scene is drawn displaced and tiles can be streamed or culled wrongly. Keep the ancestors of a tiled scene root at identity, or place the scene with translateSceneTo / rotateSceneToYaw / scaleSceneTo." + ) +} + /// Canonical scene-loading runtime. /// /// Registers one TileComponent stub per manifest entry, parents all stubs under @@ -3051,15 +3551,27 @@ private func registerTiledScene( GeometryStreamingSystem.shared.firstRangeTimestamps.removeAll() // ── 2. Set up root entity ────────────────────────────────────────────── - // The root receives a transform (identity) and a scenegraph node so tile - // stubs can be parented under it. TiledSceneComponent marks it as a tiled - // scene root for inspection and future editor workflows. + // Tile stubs are parented under the root, so it needs a transform and a + // scenegraph node. createEntity() gave it both, and registering them again + // would replace them: the root would drop out of its hierarchy on its own + // side only, with its parent still listing it and its children still + // pointing to it. They are registered only for an entity that has none. + // TiledSceneComponent marks it as a tiled scene root for inspection and + // future editor workflows. // // Root transforms are NOT propagated to streaming/culling bounds in this - // release. The manifest tile bounds are world-space values; keep the root - // at identity transform to avoid incorrect streaming/culling decisions. - registerTransformComponent(entityId: rootEntityId) - registerSceneGraphComponent(entityId: rootEntityId) + // release. The manifest tile bounds are world-space values, so the root's + // own transform is put back to identity to avoid incorrect streaming/culling + // decisions. Its parent and its children are the caller's and stay. + // + // Under the world mutation gate, like the stub batches below: the reset + // moves the entities under the root, and a scene being deserialized holds + // the gate until it has restored the root's saved transform and parent, so + // the root is always prepared after them. + withWorldMutationGate { + ensureUntoldNodeComponents(entityId: rootEntityId) + resetTiledSceneRootTransform(rootEntityId: rootEntityId) + } registerComponent(entityId: rootEntityId, componentType: TiledSceneComponent.self) if let sceneComp = scene.get(component: TiledSceneComponent.self, for: rootEntityId) { sceneComp.manifestLabel = label @@ -3127,14 +3639,12 @@ private func registerTiledScene( withWorldMutationGate { let entityId = createEntity() setEntityName(entityId: entityId, name: shared.tileId) - registerTransformComponent(entityId: entityId) if let local = scene.get(component: LocalTransformComponent.self, for: entityId) { local.boundingBox = ( min: simd_float3(shared.bounds.min[0], shared.bounds.min[1], shared.bounds.min[2]), max: simd_float3(shared.bounds.max[0], shared.bounds.max[1], shared.bounds.max[2]) ) } - registerSceneGraphComponent(entityId: entityId) registerComponent(entityId: entityId, componentType: TileComponent.self) if let tileComp = scene.get(component: TileComponent.self, for: entityId) { tileComp.tileURL = sharedURL @@ -3206,14 +3716,12 @@ private func registerTiledScene( // world space in the exported USDC). The local bounding box is set to // the tile's world-space AABB — valid because identity world transform // means local space == world space. - registerTransformComponent(entityId: entityId) if let local = scene.get(component: LocalTransformComponent.self, for: entityId) { local.boundingBox = ( min: simd_float3(tile.bounds.min[0], tile.bounds.min[1], tile.bounds.min[2]), max: simd_float3(tile.bounds.max[0], tile.bounds.max[1], tile.bounds.max[2]) ) } - registerSceneGraphComponent(entityId: entityId) registerComponent(entityId: entityId, componentType: TileComponent.self) if let tileComp = scene.get(component: TileComponent.self, for: entityId) { if let cb = tile.cellBounds, cb.min.count >= 3, cb.max.count >= 3 { @@ -3386,7 +3894,11 @@ public func setEntityAnimations(entityId: EntityID, filename: String, withExtens return } - let resourceURL = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension) + let resourceURL = resolveProbedAssetURL( + filename: filename, + ext: withExtension, + probeExtensions: ["untoldanim", "untold"] + ) guard let url = resourceURL else { handleError(.filenameNotFound, filename) return @@ -4070,26 +4582,6 @@ func computeGaussianSplatBoundingBox(_ splats: [GaussianSplat]) -> (min: simd_fl ) } -/// Reads a `.ply` or `.untoldgs` Gaussian splat asset from disk and builds its GPU buffers. -/// Returns `nil` on any failure, calling `handleError` internally — callers just guard-return. -private func buildGaussianLoadResult(filename: String, withExtension: String) -> GaussianLoadResult? { - guard let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { - handleError(.filenameNotFound, filename) - return nil - } - - if withExtension.lowercased() == "untoldgs" { - return buildGaussianLoadResultFromUntoldGS(url: url) - } - - do { - return try buildGaussianLoadResultFromPLY(url: url, sourceDescription: filename) - } catch { - handleError(.assetDataMissing, "Failed to read Gaussian splats from \(filename): \(error.localizedDescription)") - return nil - } -} - func buildGaussianLoadResultFromPLY(url: URL, sourceDescription: String) throws -> GaussianLoadResult? { let asset = try PLYReader.readGaussianAsset(from: url) let encodedSplats = asset.splats.map(encodeGaussianSplatForTBDR) @@ -4401,7 +4893,10 @@ func copyGaussianLoadResult(_ result: GaussianLoadResult, to gaussianComponent: } public enum GaussianSource { - case single(filename: String, withExtension: String) + /// `withExtension` defaults to `""`, meaning "probe": resolve `.untoldgs` first, then + /// `.ply`, the same convention `setEntityMeshAsync` uses for `.untoldpack`/`.untold`. + /// Pass an explicit extension to force one or the other regardless of what else exists. + case single(filename: String, withExtension: String = "") /// No `boundingBoxHalfExtent` here: both `setEntityGaussian(source:)` and /// `setEntityGaussianTileStreaming(source:options:)` can read a real box baked into the /// `.untoldgs` header itself (see `UntoldGSFormat.readHeader`) — an explicit override, when @@ -4422,14 +4917,17 @@ public typealias GaussianStreamingSource = GaussianSource public func setEntityGaussian(entityId: EntityID, filename: String, withExtension: String? = nil) { let (filename, withExtension) = resolveAssetFilenameExtension(filename, withExtension) - guard let result = buildGaussianLoadResult(filename: filename, withExtension: withExtension) else { + guard let url = resolveProbedAssetURL(filename: filename, ext: withExtension, probeExtensions: ["untoldgs", "ply"]) else { + handleError(.filenameNotFound, filename) + return + } + guard let result = buildGaussianLoadResult(url: url) else { return } - let sourceURL = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) withWorldMutationGate { applyGaussianLoadResult(result, to: entityId) - scene.get(component: GaussianComponent.self, for: entityId)?.sourceURL = sourceURL + scene.get(component: GaussianComponent.self, for: entityId)?.sourceURL = url } } @@ -4463,20 +4961,27 @@ public func setEntityGaussian(entityId: EntityID, source: GaussianSource) { /// `setEntityGaussianAsync` wraps this in a fire-and-forget `Task` for callers that just want /// `setEntityMeshAsync`'s plain completion-closure ergonomics. func performGaussianAsyncLoad(entityId: EntityID, filename: String, withExtension: String) async -> Bool { + // Resolved up front (cheap file-existence probing, not the heavy parse/decode below) so + // both the detached decode and the sourceURL assignment share one answer, and an empty + // withExtension probes .untoldgs then .ply instead of failing outright. + guard let url = resolveProbedAssetURL(filename: filename, ext: withExtension, probeExtensions: ["untoldgs", "ply"]) else { + handleError(.filenameNotFound, filename) + return false + } + // buildGaussianLoadResult is a plain synchronous function — awaiting it directly would // just run it inline on whatever actor called us (e.g. still the main thread, if called // from a `Task { @MainActor in ... }`). Task.detached guarantees the parse/decode/encode // work actually happens off the caller's thread regardless of where it's awaited from. let result = await Task.detached(priority: .userInitiated) { - buildGaussianLoadResult(filename: filename, withExtension: withExtension) + buildGaussianLoadResult(url: url) }.value guard let result else { return false } withWorldMutationGate { applyGaussianLoadResult(result, to: entityId) - scene.get(component: GaussianComponent.self, for: entityId)?.sourceURL = - LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) + scene.get(component: GaussianComponent.self, for: entityId)?.sourceURL = url } return true @@ -4487,12 +4992,16 @@ func performGaussianAsyncLoad(entityId: EntityID, filename: String, withExtensio /// the call site, and read the result via `completion`, exactly like `setEntityMeshAsync`. /// Gaussians have no cache/OCC layer analogous to `MeshResourceManager`, so unlike mesh /// streaming there is no separate streaming-only loader. +/// +/// `withExtension` is optional, like `setEntityMeshAsync` — omitting it (or leaving it out +/// of `filename`) probes `.untoldgs` first, then `.ply`. public func setEntityGaussianAsync( entityId: EntityID, filename: String, - withExtension: String, + withExtension: String? = nil, completion: ((Bool) -> Void)? = nil ) { + let (filename, withExtension) = resolveAssetFilenameExtension(filename, withExtension) let completionBox = completion.map { BoolCompletionBox(callback: $0) } Task { let success = await performGaussianAsyncLoad(entityId: entityId, filename: filename, withExtension: withExtension) @@ -4534,10 +5043,18 @@ public func setEntityGaussianTileStreaming( ) { switch source { case let .single(filename, ext): + // Resolved to a concrete extension now, once, rather than carrying ext == "" + // (meaning "probe") into StreamingComponent.assetExtension -- the deferred load this + // registers for reads that field directly (GeometryStreamingSystem+GaussianStreaming.swift) + // without ever coming back through this probing logic. + guard let resolved = resolveProbedAsset(filename: filename, ext: ext, probeExtensions: ["untoldgs", "ply"]) else { + handleError(.filenameNotFound, filename) + return + } setEntityGaussianStreamable( entityId: entityId, filename: filename, - withExtension: ext, + withExtension: resolved.extension, streamingRadius: options.streamingRadius, unloadRadius: options.unloadRadius, boundingBoxHalfExtent: options.boundingBoxHalfExtent, @@ -6138,8 +6655,6 @@ public func createStreamingEntity( let entityId = createEntity() // Register required components - registerTransformComponent(entityId: entityId) - registerSceneGraphComponent(entityId: entityId) registerComponent(entityId: entityId, componentType: StreamingComponent.self) guard let streaming = scene.get(component: StreamingComponent.self, for: entityId) else { diff --git a/Sources/UntoldEngine/Systems/SmallObjectCulling.swift b/Sources/UntoldEngine/Systems/SmallObjectCulling.swift new file mode 100644 index 000000000..c37c72b0e --- /dev/null +++ b/Sources/UntoldEngine/Systems/SmallObjectCulling.swift @@ -0,0 +1,84 @@ +// +// SmallObjectCulling.swift +// UntoldEngine +// +// Leaves out of a frame the objects that would only be a pixel or so tall. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd + +/// The small-object test of one frame. +/// +/// A scene with tens of thousands of parts (a building model with every clip and bolt) +/// shows most of them at a pixel or less from any distance, and each still costs a draw +/// and, near a light, a shadow draw. An object is left out when the sphere around its +/// bounds is under `minimumPixels` tall on screen. The sphere is the largest the object +/// can look, so a long thin object goes only once its whole length is that small. +struct SmallObjectCulling { + /// Objects under this many pixels tall are not drawn and cast no shadow. Zero draws + /// everything. Set with `setRendering(.smallObjectCulling(pixels:))`. + nonisolated(unsafe) static var minimumPixels: Float = 1.0 + + /// The camera, in the space the entities' world transforms are in. + let cameraPosition: simd_float3 + /// The square of the radius, per unit of distance, at which a sphere is exactly + /// `minimumPixels` tall. + let radiusPerDistanceSquared: Float + + /// - Parameters: + /// - viewportHeight: height of the view in pixels. + /// - tanHalfFovY: tangent of half the vertical field of view. + init(cameraPosition: simd_float3, minimumPixels: Float, viewportHeight: Float, tanHalfFovY: Float) { + self.cameraPosition = cameraPosition + // A sphere of radius r at distance d is r * viewportHeight / (d * tanHalfFovY) pixels tall. + let radiusPerDistance = viewportHeight > 0 ? max(minimumPixels, 0) * tanHalfFovY / viewportHeight : 0 + radiusPerDistanceSquared = radiusPerDistance * radiusPerDistance + } + + /// The test for the frame being drawn, or nil when nothing is to be culled by size: + /// the setting is zero, there is no camera, or the projection is not a perspective. + static func forCurrentFrame() -> SmallObjectCulling? { + let pixels = minimumPixels + guard pixels > 0, + let camera = CameraSystem.shared.activeCamera, + let cameraComponent = scene.get(component: CameraComponent.self, for: camera) + else { return nil } + + let projection = renderInfo.perspectiveSpace + let viewPort: simd_float2? = renderInfo.viewPort + // A perspective projection has no constant term in w; its [1][1] is 1 / tan(fovY / 2). + guard let viewPort, viewPort.y > 0, projection.columns.3.w == 0, projection.columns.1.y > 0 else { + return nil + } + return SmallObjectCulling( + cameraPosition: SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition), + minimumPixels: pixels, + viewportHeight: viewPort.y, + tanHalfFovY: 1 / projection.columns.1.y + ) + } + + /// Whether a sphere is too small on screen to be worth drawing. A sphere the camera + /// is inside of never is, and neither is one with no size: bounds that are a single + /// point say nothing about what the object draws. + func culls(center: simd_float3, radius: Float) -> Bool { + radius > 0 && radius * radius < simd_distance_squared(center, cameraPosition) * radiusPerDistanceSquared + } + + /// Whether the box between `worldMin` and `worldMax` is too small on screen. + func culls(worldMin: simd_float3, worldMax: simd_float3) -> Bool { + culls(center: (worldMin + worldMax) * 0.5, radius: simd_length(worldMax - worldMin) * 0.5) + } +} + +/// The current value set via `setRendering(.smallObjectCulling(pixels:))`. +public func getSmallObjectCullingPixels() -> Float { + SmallObjectCulling.minimumPixels +} diff --git a/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift b/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift index 0e1521815..6146e3856 100644 --- a/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift @@ -1074,10 +1074,10 @@ public class TextureStreamingSystem: @unchecked Sendable { case let .mdl(mdlTexture): return try? loader.newTexture(texture: mdlTexture, options: options) case let .url(url): - // Grayscale PNGs produce an r8Unorm Metal texture. The shader samples - // it as RGBA where G=B=0, making the mesh appear solid red. - // Detect and expand to RGBA before handing to MTKTextureLoader. - if let expanded = expandGrayscaleToRGBA(url: url, isSRGB: isSRGB, loader: loader, options: options) { + // A grayscale image loads as a texture of one channel, which a shader reading + // a color from it shows as solid red: load it with that channel in red, green + // and blue (see loadGrayscaleTextureAsRGBA). + if let expanded = loadGrayscaleTextureAsRGBA(url: url, isSRGB: isSRGB, loader: loader, options: options) { return expanded } return try? loader.newTexture(URL: url, options: options) @@ -1088,43 +1088,6 @@ public class TextureStreamingSystem: @unchecked Sendable { } } - /// If `url` points to a grayscale image, redraws it into an RGBA CGContext and - /// loads the result so Metal gets an rgba8Unorm texture instead of r8Unorm. - /// Returns nil for non-grayscale images so the caller falls back to the normal path. - private func expandGrayscaleToRGBA( - url: URL, - isSRGB: Bool, - loader: MTKTextureLoader, - options: [MTKTextureLoader.Option: Any] - ) -> MTLTexture? { - guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), - let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil), - cgImage.colorSpace?.model == .monochrome - else { return nil } - - let width = cgImage.width - let height = cgImage.height - let colorSpace = isSRGB - ? CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() - : CGColorSpaceCreateDeviceRGB() - let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue) - - guard let ctx = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: bitmapInfo.rawValue - ) else { return nil } - - ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) - - guard let rgbaImage = ctx.makeImage() else { return nil } - return try? loader.newTexture(cgImage: rgbaImage, options: options) - } - private func resampleTextureIfNeeded(_ texture: MTLTexture, targetMaxDimension: Int?, commandQueue: MTLCommandQueue) async -> MTLTexture? { guard let targetMaxDimension else { return texture } return await downsampleTexture(texture, maxDimension: targetMaxDimension, commandQueue: commandQueue) diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib index 920683cd6..dd9bb82ff 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib index 7743effbf..33bdd4d1a 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib index 91490b793..64dc64de2 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib index 1f34e29fb..ef197408e 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib index 15bf73f77..77df72c94 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib index 5d630b534..30136a12c 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib index 6d0c226f0..4bb8a5a22 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index 32df98f70..8af652309 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -71,6 +71,13 @@ public enum RenderingProperty: Sendable { /// existing texel budget on it instead, sharpening shadow edges without needing a larger /// shadow map. See `getMaxShadowCastingDistance()` to read the current value. case maxShadowCastingDistance(Float) + /// Objects whose bounds are under this many pixels tall on screen are not drawn and + /// cast no shadow. A scene with tens of thousands of small parts shows most of them + /// at a pixel or less from any distance, and each would still cost a draw. The size + /// is that of the sphere around the object's bounds, the largest it can look, so a + /// long thin object goes only once its whole length is that small. The default is 1; + /// 0 draws everything. See `getSmallObjectCullingPixels()` to read the current value. + case smallObjectCulling(pixels: Float) } public enum RenderingToggle: Sendable { @@ -129,6 +136,8 @@ public func setRendering(_ property: RenderingProperty) { applyRenderExtensionProperty(property) case let .maxShadowCastingDistance(distance): RenderPasses.maxShadowCastingDistance = max(distance, 0.001) + case let .smallObjectCulling(pixels): + SmallObjectCulling.minimumPixels = pixels.isFinite ? max(pixels, 0) : 0 } } diff --git a/Sources/UntoldEngine/Utils/SMAAAreaTex.swift b/Sources/UntoldEngine/Utils/SMAAAreaTex.swift index 11687b46d..53180617d 100644 --- a/Sources/UntoldEngine/Utils/SMAAAreaTex.swift +++ b/Sources/UntoldEngine/Utils/SMAAAreaTex.swift @@ -1,3 +1,13 @@ +// +// SMAAAreaTex.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // Generated file. Do not edit manually. import Foundation diff --git a/Sources/UntoldEngine/Utils/SMAASearchTex.swift b/Sources/UntoldEngine/Utils/SMAASearchTex.swift index b39b1f117..0a5e87299 100644 --- a/Sources/UntoldEngine/Utils/SMAASearchTex.swift +++ b/Sources/UntoldEngine/Utils/SMAASearchTex.swift @@ -1,3 +1,13 @@ +// +// SMAASearchTex.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // Generated file. Do not edit manually. import Foundation diff --git a/Sources/UntoldEngineMeshCook/UntoldMeshLODCooker.swift b/Sources/UntoldEngineMeshCook/UntoldMeshLODCooker.swift new file mode 100644 index 000000000..ec9b34807 --- /dev/null +++ b/Sources/UntoldEngineMeshCook/UntoldMeshLODCooker.swift @@ -0,0 +1,453 @@ +// +// UntoldMeshLODCooker.swift +// UntoldEngineMeshCook +// +// Builds the automatic LOD chain of a cooked `.untold` model: simplified copies of the +// model written next to it, each with the screen size from which it is detailed enough. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CMeshOptimizer +import Foundation +import simd +import UntoldEngine + +/// Settings of the automatic LOD cook. +public struct UntoldMeshLODOptions: Sendable, Equatable { + /// The share of the model's triangles each level aims for, finest first. + public var ratios: [Float] + /// Models with fewer triangles than this get no chain. + public var minimumTriangles: Int + /// How many screen pixels a triangle of a level should cover when the level takes + /// over, at `referenceViewportHeight`. Higher values switch to the simpler levels + /// sooner. + public var pixelsPerTriangle: Float + + /// The viewport height, in pixels, that a level's `screenSize` is written for. + public static let referenceViewportHeight: Float = 1080 + + /// A level is written when it has at most this share of the triangles of the + /// level before it; one that the simplifier could not reduce further is skipped. + static let worthwhileReduction: Float = 0.85 + + public init(ratios: [Float] = [0.5, 0.15, 0.03], minimumTriangles: Int = 2000, pixelsPerTriangle: Float = 4) { + self.ratios = ratios + self.minimumTriangles = minimumTriangles + self.pixelsPerTriangle = pixelsPerTriangle + } + + func validate() throws { + guard !ratios.isEmpty, ratios.count <= 8 else { + throw UntoldMeshLODError.invalidOptions("between 1 and 8 ratios are needed") + } + for (index, ratio) in ratios.enumerated() { + guard ratio > 0, ratio < 1 else { + throw UntoldMeshLODError.invalidOptions("ratio \(ratio) must be above 0 and below 1") + } + guard index == 0 || ratio < ratios[index - 1] else { + throw UntoldMeshLODError.invalidOptions("ratios must decrease: \(ratios)") + } + } + guard minimumTriangles >= 0 else { + throw UntoldMeshLODError.invalidOptions("minimumTriangles must not be negative") + } + guard pixelsPerTriangle > 0, pixelsPerTriangle.isFinite else { + throw UntoldMeshLODError.invalidOptions("pixelsPerTriangle must be above 0") + } + } +} + +/// One simplified level of a model. +public struct UntoldMeshLODLevel: Sendable, Equatable { + public var url: URL + public var triangleCount: Int + /// The level is detailed enough when the model's bounding sphere covers at most + /// this share of the viewport height (1 is the whole height), measured on a + /// viewport `UntoldMeshLODOptions.referenceViewportHeight` pixels high. + public var screenSize: Float + /// The largest deviation from the model the simplifier reports, in model units. + public var error: Float +} + +/// The LOD chain of one model. `levels` is empty when the model got none, and +/// `skipped` then says why. +public struct UntoldMeshLODChain: Sendable, Equatable { + public enum SkipReason: Sendable, Equatable { + /// Fewer triangles than `UntoldMeshLODOptions.minimumTriangles`. + case belowMinimumTriangles + /// Skinned, morphing or animation-only models keep their full meshes: their + /// levels would need the skin and the morph targets carried over. + case deforms + /// A mesh twin of a Gaussian splat already swaps to its splat. + case gaussianTwin + /// The file is itself a level of another model. + case isLevel + /// A file that is not a level of this model already has the name one of its + /// levels needs; it is left as it is. + case levelNameTaken + /// The simplifier could not reduce the model by a worthwhile amount. + case irreducible + } + + public var triangleCount: Int + public var levels: [UntoldMeshLODLevel] + public var skipped: SkipReason? +} + +public enum UntoldMeshLODError: Error, CustomStringConvertible { + case invalidOptions(String) + case unreadableModel(path: String, reason: String) + case unreadableManifest(path: String, reason: String) + case writeFailed(path: String, reason: String) + + public var description: String { + switch self { + case let .invalidOptions(reason): + "Invalid LOD options: \(reason)" + case let .unreadableModel(path, reason): + "Could not read the model \(path): \(reason)" + case let .unreadableManifest(path, reason): + "Could not read the pack manifest \(path): \(reason)" + case let .writeFailed(path, reason): + "Could not write \(path): \(reason)" + } + } +} + +public enum UntoldMeshLODCooker { + /// The file of level `index` (1 is the first simplified one) of the model at `modelURL`. + public static func levelURL(forModelAt modelURL: URL, level index: Int) -> URL { + let stem = modelURL.deletingPathExtension().lastPathComponent + return modelURL.deletingLastPathComponent() + .appendingPathComponent("\(stem)_LOD\(index)") + .appendingPathExtension(modelURL.pathExtension) + } + + /// Builds the LOD chain of the model at `modelURL` and writes its levels next to it + /// as `_LOD1.untold`, `_LOD2.untold`, ... Levels of an earlier cook are + /// removed first, so the files on disk are always those of the returned chain. + /// + /// Every mesh is simplified towards each of `options.ratios`, within a deviation + /// of one triangle of the level (the model's diameter over the square root of the + /// level's triangle count). Vertices that two meshes of the model share stay in + /// place, so material boundaries do not open. The texture seams of a textured mesh + /// keep their texture coordinates, unless they hold a level well above its target. + @discardableResult + public static func cookChain(forModelAt modelURL: URL, options: UntoldMeshLODOptions = UntoldMeshLODOptions()) throws -> UntoldMeshLODChain { + try options.validate() + + let fileData: Data + let decoded: UntoldDecodedAsset + let reader = UntoldReader() + do { + fileData = try Data(contentsOf: modelURL, options: .mappedIfSafe) + decoded = try reader.readAsset(from: fileData) + } catch { + throw UntoldMeshLODError.unreadableModel(path: modelURL.path, reason: String(describing: error)) + } + + let triangleCount = decoded.meshes.reduce(0) { $0 + Int($1.indexCount) / 3 } + func skip(_ reason: UntoldMeshLODChain.SkipReason) -> UntoldMeshLODChain { + UntoldMeshLODChain(triangleCount: triangleCount, levels: [], skipped: reason) + } + if decoded.header.flags & UntoldFileFlags.generatedLODLevel != 0 { + // Not a model: the levels around it belong to the model it was made from. + return skip(.isLevel) + } + let fileManager = FileManager.default + for index in 1 ... options.ratios.count { + let url = levelURL(forModelAt: modelURL, level: index) + if fileManager.fileExists(atPath: url.path), !isGeneratedLevel(url) { + return skip(.levelNameTaken) + } + } + try removeLevels(ofModelAt: modelURL) + if decoded.header.fileType == .animation || !decoded.skins.isEmpty || !decoded.morphTargets.isEmpty { + return skip(.deforms) + } + if !decoded.gaussianAssets.isEmpty { + return skip(.gaussianTwin) + } + if triangleCount < max(options.minimumTriangles, 1) { + return skip(.belowMinimumTriangles) + } + + var unpacked: [UntoldLODMesh] + let transforms: [simd_float4x4] + do { + let vertexChunk = try reader.readChunkData(.vertexData, from: fileData, entries: decoded.chunks) + let indexChunk = try reader.readChunkData(.indexData, from: fileData, entries: decoded.chunks) + unpacked = try decoded.meshes.map { try unpack($0, vertexChunk: vertexChunk, indexChunk: indexChunk) } + transforms = try meshTransforms(decoded) + } catch { + throw UntoldMeshLODError.unreadableModel(path: modelURL.path, reason: String(describing: error)) + } + applyVertexFlags(to: &unpacked, transforms: transforms, textured: decoded.meshes.map { usesTextures($0, in: decoded) }) + let meshes = unpacked + let parts = meshes.map(UntoldLODMeshParts.init) + let diameter = modelDiameter(meshes: meshes, transforms: transforms) + let scales = transforms.map(largestAxisScale) + + var levels: [UntoldMeshLODLevel] = [] + var previousTriangles = triangleCount + for ratio in options.ratios { + // One triangle of the level, if its triangles tiled the model's bounding + // square: a deviation of that size is as small on screen as the level's + // triangles are when it takes over. + let plannedTriangles = max(Float(triangleCount) * ratio, 1) + let errorLimit = diameter / plannedTriangles.squareRoot() + + var meshLevels = [UntoldLODMeshLevel?](repeating: nil, count: meshes.count) + meshLevels.withUnsafeMutableBufferPointer { results in + // Each iteration writes its own slot. + nonisolated(unsafe) let results = results + DispatchQueue.concurrentPerform(iterations: meshes.count) { index in + let scale = scales[index] + results[index] = UntoldMeshLODSimplifier.level( + of: meshes[index], + parts: parts[index], + ratio: ratio, + errorLimit: scale > 0 ? errorLimit / scale : errorLimit + ) + } + } + let simplified = meshLevels.compactMap { $0 } + let levelTriangles = simplified.reduce(0) { $0 + $1.triangleCount } + guard simplified.count == meshes.count, + Float(levelTriangles) <= Float(previousTriangles) * UntoldMeshLODOptions.worthwhileReduction + else { continue } + + let url = levelURL(forModelAt: modelURL, level: levels.count + 1) + do { + let file = try UntoldMeshLODWriter.levelFile(source: decoded, sourceData: fileData, levels: simplified) + try file.write(to: url, options: .atomic) + } catch { + throw UntoldMeshLODError.writeFailed(path: url.path, reason: String(describing: error)) + } + levels.append(UntoldMeshLODLevel( + url: url, + triangleCount: levelTriangles, + screenSize: screenSize(triangleCount: levelTriangles, pixelsPerTriangle: options.pixelsPerTriangle), + error: zip(simplified, scales).map { $0.error * $1 }.max() ?? 0 + )) + previousTriangles = levelTriangles + } + + return UntoldMeshLODChain( + triangleCount: triangleCount, + levels: levels, + skipped: levels.isEmpty ? .irreducible : nil + ) + } + + /// The screen size at which `triangleCount` triangles are about `pixelsPerTriangle` + /// pixels each: the model's bounding square is `screenSize * referenceViewportHeight` + /// pixels on a side. + static func screenSize(triangleCount: Int, pixelsPerTriangle: Float) -> Float { + (pixelsPerTriangle * Float(max(triangleCount, 1))).squareRoot() / UntoldMeshLODOptions.referenceViewportHeight + } + + /// Deletes the level files a previous cook wrote for the model at `modelURL`. + public static func removeLevels(ofModelAt modelURL: URL) throws { + let fileManager = FileManager.default + var index = 1 + while true { + let url = levelURL(forModelAt: modelURL, level: index) + guard fileManager.fileExists(atPath: url.path) else { return } + // Only what a cook wrote: a file that happens to be named like a level stays. + guard isGeneratedLevel(url) else { return } + do { + try fileManager.removeItem(at: url) + } catch { + throw UntoldMeshLODError.writeFailed(path: url.path, reason: error.localizedDescription) + } + index += 1 + } + } + + private static func isGeneratedLevel(_ url: URL) -> Bool { + guard let handle = try? FileHandle(forReadingFrom: url) else { return false } + defer { try? handle.close() } + guard let head = try? handle.read(upToCount: 256), + let header = try? UntoldFileHeaderV1.decode(from: UntoldBinaryReader(data: head)) + else { return false } + return header.flags & UntoldFileFlags.generatedLODLevel != 0 + } + + // MARK: - Reading the model + + private static func unpack(_ record: UntoldMeshRecordV1, vertexChunk: Data, indexChunk: Data) throws -> UntoldLODMesh { + let vertexStart = Int(record.vertexDataOffset) + let vertexSize = Int(record.vertexCount) * UntoldLODMesh.vertexStride + let indexStart = Int(record.indexDataOffset) + let indexCount = Int(record.indexCount) - Int(record.indexCount) % 3 + let indexSize = indexCount * (record.indexType == .uint16 ? 2 : 4) + guard Int(record.vertexStrideBytes) == UntoldLODMesh.vertexStride, + vertexStart + vertexSize <= vertexChunk.count, + indexStart + indexSize <= indexChunk.count + else { + throw UntoldValidationError.invalidVertexDataRange( + offset: record.vertexDataOffset, + size: record.vertexDataSizeBytes, + chunkSize: UInt64(vertexChunk.count) + ) + } + + let vertexCount = Int(record.vertexCount) + var indices = [UInt32](repeating: 0, count: indexCount) + indexChunk.withUnsafeBytes { raw in + for position in 0 ..< indexCount { + let index = if record.indexType == .uint16 { + UInt32(UInt16(littleEndian: raw.loadUnaligned(fromByteOffset: indexStart + position * 2, as: UInt16.self))) + } else { + UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: indexStart + position * 4, as: UInt32.self)) + } + // An index past the vertices would read outside the buffers below. + indices[position] = index < UInt32(vertexCount) ? index : 0 + } + } + let base = vertexChunk.startIndex + vertexStart + return UntoldLODMesh(vertexData: vertexChunk.subdata(in: base ..< base + vertexSize), indices: indices) + } + + /// For each mesh record, the transform from its mesh space to the model's. + private static func meshTransforms(_ decoded: UntoldDecodedAsset) throws -> [simd_float4x4] { + let entities = Dictionary(decoded.entities.map { ($0.entityId, $0) }, uniquingKeysWith: { first, _ in first }) + var resolved: [UInt32: simd_float4x4] = [:] + func worldTransform(of entity: UntoldEntityRecordV1, depth: Int) throws -> simd_float4x4 { + if let transform = resolved[entity.entityId] { + return transform + } + guard depth <= decoded.entities.count else { + throw RuntimeAssetLoaderError.malformedAsset("Cycle in the entity hierarchy at entity \(entity.entityId)") + } + let transform: simd_float4x4 = if entity.parentEntityId != UntoldFormat.invalidIndex, let parent = entities[entity.parentEntityId] { + try simd_mul(worldTransform(of: parent, depth: depth + 1), entity.localTransform) + } else { + simd_mul(decoded.header.rootTransform, entity.localTransform) + } + resolved[entity.entityId] = transform + return transform + } + return try decoded.meshes.map { mesh in + guard let entity = entities[mesh.entityId] else { return matrix_identity_float4x4 } + return try worldTransform(of: entity, depth: 0) + } + } + + private static func largestAxisScale(_ transform: simd_float4x4) -> Float { + max( + simd_length(simd_make_float3(transform.columns.0)), + simd_length(simd_make_float3(transform.columns.1)), + simd_length(simd_make_float3(transform.columns.2)) + ) + } + + private static func modelDiameter(meshes: [UntoldLODMesh], transforms: [simd_float4x4]) -> Float { + var lower = SIMD3(repeating: .greatestFiniteMagnitude) + var upper = SIMD3(repeating: -.greatestFiniteMagnitude) + for (mesh, transform) in zip(meshes, transforms) where mesh.vertexCount > 0 { + for corner in 0 ..< 8 { + let point = SIMD4( + corner & 1 == 0 ? mesh.boundsMin.x : mesh.boundsMax.x, + corner & 2 == 0 ? mesh.boundsMin.y : mesh.boundsMax.y, + corner & 4 == 0 ? mesh.boundsMin.z : mesh.boundsMax.z, + 1 + ) + let moved = simd_make_float3(simd_mul(transform, point)) + lower = simd_min(lower, moved) + upper = simd_max(upper, moved) + } + } + let diameter = simd_length(upper - lower) + return diameter.isFinite ? diameter : 0 + } + + // MARK: - Vertices the simplifier must respect + + /// Whether the material of `mesh` reads a texture, and so its texture coordinates matter. + private static func usesTextures(_ mesh: UntoldMeshRecordV1, in decoded: UntoldDecodedAsset) -> Bool { + guard mesh.materialIndex != UntoldFormat.invalidIndex, Int(mesh.materialIndex) < decoded.materials.count else { + return false + } + let material = decoded.materials[Int(mesh.materialIndex)] + return [ + material.baseColorTextureIndex, + material.normalTextureIndex, + material.metallicTextureIndex, + material.roughnessTextureIndex, + material.emissiveTextureIndex, + material.occlusionTextureIndex, + material.heightTextureIndex, + ].contains { $0 != UntoldFormat.invalidIndex } + } + + /// Sets the simplifier flags of every mesh: a vertex whose position another mesh of + /// the model also has is locked (the meshes are the materials of one surface and + /// must keep meeting there), and a vertex on a texture seam of a textured mesh keeps + /// its texture coordinates. + static func applyVertexFlags(to meshes: inout [UntoldLODMesh], transforms: [simd_float4x4], textured: [Bool]) { + let lock = UInt8(meshopt_SimplifyVertex_Lock) + let protect = UInt8(meshopt_SimplifyVertex_Protect) + + // Model-space position → the mesh that has it, or -1 once a second mesh does. + var owners: [SIMD3: Int32] = [:] + if meshes.count > 1 { + owners.reserveCapacity(meshes.reduce(0) { $0 + $1.vertexCount }) + for (meshIndex, mesh) in meshes.enumerated() { + for vertex in 0 ..< mesh.vertexCount where Int(mesh.positionRemap[vertex]) == vertex { + let key = positionKey(mesh.position(vertex), transform: transforms[meshIndex]) + if let owner = owners[key] { + if owner != Int32(meshIndex) { + owners[key] = -1 + } + } else { + owners[key] = Int32(meshIndex) + } + } + } + } + + for meshIndex in meshes.indices { + let mesh = meshes[meshIndex] + var flags = [UInt8](repeating: 0, count: mesh.vertexCount) + var locks = [UInt8](repeating: 0, count: mesh.vertexCount) + var hasFlags = false + var hasLocks = false + mesh.vertexData.withUnsafeBytes { raw in + for vertex in 0 ..< mesh.vertexCount { + let first = Int(mesh.positionRemap[vertex]) + if first != vertex { + guard textured[meshIndex] else { continue } + // uv0 is the two half floats at byte 20 of the record. + let texture = raw.loadUnaligned(fromByteOffset: vertex * UntoldLODMesh.vertexStride + 20, as: UInt32.self) + let firstTexture = raw.loadUnaligned(fromByteOffset: first * UntoldLODMesh.vertexStride + 20, as: UInt32.self) + if texture != firstTexture { + flags[vertex] |= protect + hasFlags = true + } + } else if !owners.isEmpty, owners[positionKey(mesh.position(vertex), transform: transforms[meshIndex])] == -1 { + flags[vertex] |= lock + locks[vertex] |= lock + hasFlags = true + hasLocks = true + } + } + } + meshes[meshIndex].flags = hasFlags ? flags : [] + meshes[meshIndex].lockFlags = hasLocks ? locks : [] + } + } + + private static func positionKey(_ position: SIMD3, transform: simd_float4x4) -> SIMD3 { + // Meshes split from one surface share its transform, so equal points stay bit + // for bit equal through it. Adding zero folds -0 into +0. + let moved = simd_make_float3(simd_mul(transform, SIMD4(position, 1))) + SIMD3(repeating: 0) + return SIMD3(moved.x.bitPattern, moved.y.bitPattern, moved.z.bitPattern) + } +} diff --git a/Sources/UntoldEngineMeshCook/UntoldMeshLODSimplifier.swift b/Sources/UntoldEngineMeshCook/UntoldMeshLODSimplifier.swift new file mode 100644 index 000000000..b73697529 --- /dev/null +++ b/Sources/UntoldEngineMeshCook/UntoldMeshLODSimplifier.swift @@ -0,0 +1,571 @@ +// +// UntoldMeshLODSimplifier.swift +// UntoldEngineMeshCook +// +// Geometry side of the automatic LOD cook: reduces one mesh of a cooked model to a +// share of its triangles with meshoptimizer's simplifier. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CMeshOptimizer +import Foundation +import simd +import UntoldEngine + +/// One mesh of a cooked model, unpacked for the simplifier. +struct UntoldLODMesh { + /// `pbrStaticV1` records, `UntoldLODMesh.vertexStride` bytes per vertex. + var vertexData: Data + var vertexCount: Int + var indices: [UInt32] + /// x, y, z per vertex: what the simplifier reads and the thinning step moves. + var positions: [Float] + /// Unpacked normals, x, y, z per vertex: the simplifier's attribute metric. + var normals: [Float] + /// For each vertex, the first vertex at the same position: the points of the + /// surface, whatever their normals and texture coordinates. + var positionRemap: [UInt32] + /// `meshopt_SimplifyVertex_*` flags per vertex, or empty when no vertex has any. + var flags: [UInt8] + /// `flags` without the texture seams: only the vertices that must stay in place. + /// Empty when no vertex is locked. + var lockFlags: [UInt8] + var boundsMin: SIMD3 + var boundsMax: SIMD3 + + static let vertexStride = 32 + + var triangleCount: Int { + indices.count / 3 + } + + var diagonal: Float { + simd_length(boundsMax - boundsMin) + } + + /// Unpacks `vertexData` (positions and 10:10:10:2 normals) and computes the bounds. + init(vertexData: Data, indices: [UInt32]) { + self.vertexData = vertexData + self.indices = indices + vertexCount = vertexData.count / Self.vertexStride + flags = [] + lockFlags = [] + + var positions = [Float](repeating: 0, count: vertexCount * 3) + var normals = [Float](repeating: 0, count: vertexCount * 3) + var lower = SIMD3(repeating: .greatestFiniteMagnitude) + var upper = SIMD3(repeating: -.greatestFiniteMagnitude) + let count = vertexCount + vertexData.withUnsafeBytes { raw in + positions.withUnsafeMutableBufferPointer { positions in + normals.withUnsafeMutableBufferPointer { normals in + for vertex in 0 ..< count { + let record = vertex * Self.vertexStride + let x = Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record, as: UInt32.self))) + let y = Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 4, as: UInt32.self))) + let z = Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 8, as: UInt32.self))) + positions[vertex * 3] = x + positions[vertex * 3 + 1] = y + positions[vertex * 3 + 2] = z + let point = SIMD3(x, y, z) + lower = simd_min(lower, point) + upper = simd_max(upper, point) + + let packed = UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 12, as: UInt32.self)) + let normal = UntoldVertexPacking.unpackNormal(packed) + normals[vertex * 3] = normal.x + normals[vertex * 3 + 1] = normal.y + normals[vertex * 3 + 2] = normal.z + } + } + } + } + var remap = [UInt32](repeating: 0, count: vertexCount) + positions.withUnsafeBufferPointer { positions in + meshopt_generatePositionRemap(&remap, positions.baseAddress, count, MemoryLayout.stride * 3) + } + positionRemap = remap + self.positions = positions + self.normals = normals + boundsMin = count > 0 ? lower : .zero + boundsMax = count > 0 ? upper : .zero + } + + func position(_ vertex: Int) -> SIMD3 { + SIMD3(positions[vertex * 3], positions[vertex * 3 + 1], positions[vertex * 3 + 2]) + } +} + +/// One level of one mesh: a compact vertex buffer and its triangles. +struct UntoldLODMeshLevel { + var vertexData: Data + var vertexCount: Int + var indices: [UInt32] + var boundsMin: SIMD3 + var boundsMax: SIMD3 + /// The deviation from the source mesh the simplifier reports, in mesh units. + var error: Float + + var triangleCount: Int { + indices.count / 3 + } +} + +/// How a mesh splits into a connected body and clutter. +/// +/// Vegetation and similar aggregates are thousands of small disconnected pieces (leaf +/// cards, twigs). Edge collapse can simplify each piece but can only remove triangles +/// beyond that by shrinking pieces away, which thins a canopy out: at 3 % of the +/// triangles a tree keeps under a third of its leaf area. So the pieces of an +/// aggregate are handled apart from the connected surfaces: collapsed while they keep +/// their outline, then thinned evenly through space, and the survivors enlarged to +/// give back the area that was removed (stochastic pruning, as film and game foliage +/// pipelines do). +struct UntoldLODMeshParts { + /// Component of each vertex; vertices at the same position share a component. + var componentOfVertex: [Int32] + var componentCount: Int + var componentCenters: [SIMD3] + /// Index triples of the connected surfaces. + var bodyIndices: [UInt32] + /// Index triples of the small pieces of an aggregate; empty for any other mesh. + var clutterIndices: [UInt32] + var clutterArea: Float + /// Median bounding-box diagonal of the clutter pieces. + var clutterPieceSize: Float + + /// A piece is clutter when its bounding box is under this share of the mesh's. + static let clutterSizeShare: Float = 0.05 + /// A mesh is an aggregate from this many clutter pieces ... + static let aggregateMinimumPieces = 64 + /// ... that hold at least this share of its area. + static let aggregateMinimumAreaShare: Float = 0.25 + + init(mesh: UntoldLODMesh) { + let vertexCount = mesh.vertexCount + let triangleCount = mesh.triangleCount + + var parent = [Int32](repeating: 0, count: vertexCount) + var componentOfVertex = [Int32](repeating: -1, count: vertexCount) + var componentCount = 0 + parent.withUnsafeMutableBufferPointer { parent in + for vertex in 0 ..< vertexCount { + parent[vertex] = Int32(vertex) + } + func find(_ start: Int) -> Int { + var node = start + while Int(parent[node]) != node { + parent[node] = parent[Int(parent[node])] + node = Int(parent[node]) + } + return node + } + mesh.positionRemap.withUnsafeBufferPointer { remap in + mesh.indices.withUnsafeBufferPointer { indices in + for triangle in 0 ..< triangleCount { + let a = find(Int(remap[Int(indices[triangle * 3])])) + let b = find(Int(remap[Int(indices[triangle * 3 + 1])])) + let c = find(Int(remap[Int(indices[triangle * 3 + 2])])) + if a != b { + parent[b] = Int32(a) + } + let root = find(a) + if c != root { + parent[find(c)] = Int32(root) + } + } + } + componentOfVertex.withUnsafeMutableBufferPointer { componentOfVertex in + // A component takes its id from its root, the first time any of its + // vertices is met. + for vertex in 0 ..< vertexCount { + let root = find(Int(remap[vertex])) + if componentOfVertex[root] < 0 { + componentOfVertex[root] = Int32(componentCount) + componentCount += 1 + } + componentOfVertex[vertex] = componentOfVertex[root] + } + } + } + } + + var triangles = [Int](repeating: 0, count: componentCount) + var areas = [Float](repeating: 0, count: componentCount) + var lower = [SIMD3](repeating: SIMD3(repeating: .greatestFiniteMagnitude), count: componentCount) + var upper = [SIMD3](repeating: SIMD3(repeating: -.greatestFiniteMagnitude), count: componentCount) + for triangle in 0 ..< triangleCount { + let a = Int(mesh.indices[triangle * 3]) + let pa = mesh.position(a) + let pb = mesh.position(Int(mesh.indices[triangle * 3 + 1])) + let pc = mesh.position(Int(mesh.indices[triangle * 3 + 2])) + let component = Int(componentOfVertex[a]) + triangles[component] += 1 + areas[component] += 0.5 * simd_length(simd_cross(pb - pa, pc - pa)) + lower[component] = simd_min(lower[component], simd_min(pa, simd_min(pb, pc))) + upper[component] = simd_max(upper[component], simd_max(pa, simd_max(pb, pc))) + } + + let clutterLimit = mesh.diagonal * Self.clutterSizeShare + var isClutter = [Bool](repeating: false, count: componentCount) + var clutterSizes: [Float] = [] + var clutterArea: Float = 0 + var totalArea: Float = 0 + for component in 0 ..< componentCount where triangles[component] > 0 { + totalArea += areas[component] + let size = simd_length(upper[component] - lower[component]) + if size < clutterLimit { + isClutter[component] = true + clutterSizes.append(size) + clutterArea += areas[component] + } + } + let isAggregate = clutterSizes.count >= Self.aggregateMinimumPieces + && clutterArea >= totalArea * Self.aggregateMinimumAreaShare + + self.componentOfVertex = componentOfVertex + self.componentCount = componentCount + componentCenters = zip(lower, upper).map { ($0 + $1) * 0.5 } + if isAggregate { + var body: [UInt32] = [] + var clutter: [UInt32] = [] + clutter.reserveCapacity(mesh.indices.count) + for triangle in 0 ..< triangleCount { + let range = triangle * 3 ..< triangle * 3 + 3 + if isClutter[Int(componentOfVertex[Int(mesh.indices[triangle * 3])])] { + clutter.append(contentsOf: mesh.indices[range]) + } else { + body.append(contentsOf: mesh.indices[range]) + } + } + bodyIndices = body + clutterIndices = clutter + self.clutterArea = clutterArea + clutterSizes.sort() + clutterPieceSize = clutterSizes[clutterSizes.count / 2] + } else { + bodyIndices = mesh.indices + clutterIndices = [] + self.clutterArea = 0 + clutterPieceSize = 0 + } + } +} + +enum UntoldMeshLODSimplifier { + /// Weight of each normal component against position in the simplifier's error. + static let normalWeight: Float = 0.5 + /// The texture seams are released when they hold a mesh this far above its target. + static let seamReleaseOvershoot: Float = 1.25 + /// Share of a clutter piece's size it may deviate by while it is collapsed. + static let clutterErrorShare: Float = 0.25 + /// Survivors of the thinning are never enlarged beyond this factor, and the thinning + /// stops where a larger one would be needed to keep the area. + static let clutterMaximumScale: Float = 4 + + /// Reduces `mesh` towards `ratio` of its triangles, deviating from it by at most + /// `errorLimit` (mesh units). The result can hold more triangles than asked for: + /// the simplifier stops at the error limit and at what the topology allows. + static func level(of mesh: UntoldLODMesh, parts: UntoldLODMeshParts, ratio: Float, errorLimit: Float) -> UntoldLODMeshLevel { + var positions = mesh.positions + var movedVertices: [Int] = [] + var error: Float = 0 + + var indices = simplifyBody(mesh: mesh, parts: parts, ratio: ratio, errorLimit: errorLimit, error: &error) + if !parts.clutterIndices.isEmpty { + indices += simplifyClutter( + mesh: mesh, + parts: parts, + ratio: ratio, + errorLimit: errorLimit, + positions: &positions, + movedVertices: &movedVertices, + error: &error + ) + } + return compact(mesh: mesh, indices: indices, positions: positions, movedVertices: movedVertices, error: error) + } + + // MARK: - Connected surfaces + + private static func simplifyBody( + mesh: UntoldLODMesh, + parts: UntoldLODMeshParts, + ratio: Float, + errorLimit: Float, + error: inout Float + ) -> [UInt32] { + let source = parts.bodyIndices + guard !source.isEmpty else { return [] } + let target = targetIndexCount(source.count, ratio: ratio) + guard target < source.count else { return source } + + // Small detached parts go first (prune): at the size this level is drawn they + // are under the error limit, which is what it is chosen for. + var options = UInt32(meshopt_SimplifyPermissive | meshopt_SimplifyPrune) + | UInt32(meshopt_SimplifyErrorAbsolute | meshopt_SimplifyErrorClamped) + if !parts.clutterIndices.isEmpty { + options |= UInt32(meshopt_SimplifySparse) + } + + /// The texture seams are kept unless they hold the mesh above the target (a model + /// unwrapped into many small islands). A level is drawn where its triangles are + /// a few pixels, and so is what a collapse across a seam does to the texture: + /// rather than keep triangles nobody can tell apart, the seams are let go. + func collapse(_ options: UInt32) -> (indices: [UInt32], error: Float) { + var keptError: Float = 0 + let kept = simplify(mesh: mesh, indices: source, flags: mesh.flags, target: target, errorLimit: errorLimit, options: options, error: &keptError) + guard Float(kept.count) > Float(target) * seamReleaseOvershoot, mesh.flags != mesh.lockFlags else { + return (kept, keptError) + } + var releasedError: Float = 0 + let released = simplify(mesh: mesh, indices: source, flags: mesh.lockFlags, target: target, errorLimit: errorLimit, options: options, error: &releasedError) + return !released.isEmpty && released.count < kept.count ? (released, releasedError) : (kept, keptError) + } + + var result = collapse(options) + if result.indices.isEmpty { + // Everything was small enough to prune. A mesh with no triangles cannot be + // drawn, so keep its shape and let the collapse alone reduce it. + result = collapse(options & ~UInt32(meshopt_SimplifyPrune)) + } + if result.indices.isEmpty { + return source + } + error = max(error, result.error) + return result.indices + } + + // MARK: - Aggregates + + private static func simplifyClutter( + mesh: UntoldLODMesh, + parts: UntoldLODMeshParts, + ratio: Float, + errorLimit: Float, + positions: inout [Float], + movedVertices: inout [Int], + error: inout Float + ) -> [UInt32] { + let source = parts.clutterIndices + let target = targetIndexCount(source.count, ratio: ratio) + guard target < source.count else { return source } + + // 1. Collapse inside the pieces while they keep their outline. + let pieceLimit = min(errorLimit, parts.clutterPieceSize * clutterErrorShare) + let options = UInt32(meshopt_SimplifyPermissive | meshopt_SimplifySparse) + | UInt32(meshopt_SimplifyErrorAbsolute | meshopt_SimplifyErrorClamped) + var collapseError: Float = 0 + var collapsed = simplify(mesh: mesh, indices: source, flags: mesh.flags, target: target, errorLimit: pieceLimit, options: options, error: &collapseError) + if collapsed.isEmpty { + collapsed = source + } else { + error = max(error, collapseError) + } + + // 2. Thin the pieces out evenly: along a space-filling curve through their + // centres, every piece adds its share of the triangle budget and is kept when + // the running total passes a whole one. + var pieceTriangles = [Int32](repeating: 0, count: parts.componentCount) + var collapsedArea: Float = 0 + for triangle in 0 ..< collapsed.count / 3 { + let a = Int(collapsed[triangle * 3]) + pieceTriangles[Int(parts.componentOfVertex[a])] += 1 + let b = Int(collapsed[triangle * 3 + 1]) + let c = Int(collapsed[triangle * 3 + 2]) + collapsedArea += 0.5 * simd_length(simd_cross(mesh.position(b) - mesh.position(a), mesh.position(c) - mesh.position(a))) + } + var keep = [Bool](repeating: true, count: parts.componentCount) + if collapsed.count > target { + let pieces = (0 ..< parts.componentCount).filter { pieceTriangles[$0] > 0 } + let ordered = mortonOrder(pieces, centers: parts.componentCenters, lower: mesh.boundsMin, upper: mesh.boundsMax) + // Never thinner than the enlargement of step 3 can make up for: below that + // share the aggregate would show through, whatever the ratio asked for. + let fullestShare = collapsedArea > 0 + ? Double(parts.clutterArea / (collapsedArea * clutterMaximumScale * clutterMaximumScale)) + : 1 + let share = min(max(Double(target) / Double(collapsed.count), fullestShare), 1) + var credit = 0.5 + for piece in ordered { + credit += share + if credit >= 1 { + credit -= 1 + } else { + keep[piece] = false + } + } + } + var kept: [UInt32] = [] + kept.reserveCapacity(min(collapsed.count, target + 3)) + var keptArea: Float = 0 + for triangle in 0 ..< collapsed.count / 3 { + let a = Int(collapsed[triangle * 3]) + guard keep[Int(parts.componentOfVertex[a])] else { continue } + let b = Int(collapsed[triangle * 3 + 1]) + let c = Int(collapsed[triangle * 3 + 2]) + kept.append(contentsOf: [UInt32(a), UInt32(b), UInt32(c)]) + keptArea += 0.5 * simd_length(simd_cross(mesh.position(b) - mesh.position(a), mesh.position(c) - mesh.position(a))) + } + guard !kept.isEmpty, keptArea > 0 else { + return collapsed + } + + // 3. Enlarge the survivors, each about its own centre, to the area the clutter + // had: the aggregate keeps its density and its silhouette. Positions stay + // inside the mesh's bounds, so every level fits the bounds of the first. + let scale = min(max((parts.clutterArea / keptArea).squareRoot(), 1), clutterMaximumScale) + if scale > 1.001 { + var isMoved = [Bool](repeating: false, count: mesh.vertexCount) + for index in kept { + let vertex = Int(index) + guard !isMoved[vertex] else { continue } + isMoved[vertex] = true + movedVertices.append(vertex) + let center = parts.componentCenters[Int(parts.componentOfVertex[vertex])] + let moved = simd_clamp(center + (mesh.position(vertex) - center) * scale, mesh.boundsMin, mesh.boundsMax) + positions[vertex * 3] = moved.x + positions[vertex * 3 + 1] = moved.y + positions[vertex * 3 + 2] = moved.z + } + } + return kept + } + + /// `pieces` in the order of the Morton codes of their centres within the bounds. + private static func mortonOrder(_ pieces: [Int], centers: [SIMD3], lower: SIMD3, upper: SIMD3) -> [Int] { + let extent = simd_max(upper - lower, SIMD3(repeating: 1e-9)) + func spread(_ value: UInt32) -> UInt64 { + var bits = UInt64(value & 0x1FFFFF) + bits = (bits | (bits << 32)) & 0x1F_0000_0000_FFFF + bits = (bits | (bits << 16)) & 0x1F_0000_FF00_00FF + bits = (bits | (bits << 8)) & 0x100F_00F0_0F00_F00F + bits = (bits | (bits << 4)) & 0x10C3_0C30_C30C_30C3 + bits = (bits | (bits << 2)) & 0x1249_2492_4924_9249 + return bits + } + let codes: [UInt64] = pieces.map { piece in + let unit = simd_clamp((centers[piece] - lower) / extent, .zero, SIMD3(repeating: 1)) + let cell = unit * 2_097_151 + return spread(UInt32(cell.x)) | (spread(UInt32(cell.y)) << 1) | (spread(UInt32(cell.z)) << 2) + } + return pieces.indices.sorted { codes[$0] != codes[$1] ? codes[$0] < codes[$1] : pieces[$0] < pieces[$1] }.map { pieces[$0] } + } + + // MARK: - meshoptimizer + + private static func targetIndexCount(_ indexCount: Int, ratio: Float) -> Int { + max(Int(Float(indexCount / 3) * ratio), 1) * 3 + } + + private static func simplify( + mesh: UntoldLODMesh, + indices: [UInt32], + flags: [UInt8], + target: Int, + errorLimit: Float, + options: UInt32, + error: inout Float + ) -> [UInt32] { + var destination = [UInt32](repeating: 0, count: indices.count) + var resultError: Float = 0 + let weights = [Float](repeating: normalWeight, count: 3) + let floatStride = MemoryLayout.stride * 3 + let count = mesh.positions.withUnsafeBufferPointer { positions in + mesh.normals.withUnsafeBufferPointer { normals in + flags.withUnsafeBufferPointer { flags in + meshopt_simplifyWithAttributes( + &destination, + indices, + indices.count, + positions.baseAddress, + mesh.vertexCount, + floatStride, + normals.baseAddress, + floatStride, + weights, + weights.count, + flags.isEmpty ? nil : flags.baseAddress, + target, + errorLimit, + options, + &resultError + ) + } + } + } + destination.removeLast(destination.count - count) + error = resultError + return destination + } + + /// Orders the triangles for the GPU's vertex cache and keeps only the vertices they use. + private static func compact( + mesh: UntoldLODMesh, + indices: [UInt32], + positions: [Float], + movedVertices: [Int], + error: Float + ) -> UntoldLODMeshLevel { + var source = mesh.vertexData + if !movedVertices.isEmpty { + source.withUnsafeMutableBytes { raw in + for vertex in movedVertices { + for axis in 0 ..< 3 { + raw.storeBytes( + of: positions[vertex * 3 + axis].bitPattern.littleEndian, + toByteOffset: vertex * UntoldLODMesh.vertexStride + axis * 4, + as: UInt32.self + ) + } + } + } + } + + var ordered = [UInt32](repeating: 0, count: indices.count) + meshopt_optimizeVertexCache(&ordered, indices, indices.count, mesh.vertexCount) + + // optimizeVertexFetch writes at most one record per source vertex. + var vertices = Data(count: mesh.vertexCount * UntoldLODMesh.vertexStride) + let vertexCount = vertices.withUnsafeMutableBytes { destination in + source.withUnsafeBytes { source in + meshopt_optimizeVertexFetch( + destination.baseAddress, + &ordered, + ordered.count, + source.baseAddress, + mesh.vertexCount, + UntoldLODMesh.vertexStride + ) + } + } + vertices.removeSubrange(vertexCount * UntoldLODMesh.vertexStride ..< vertices.count) + + var lower = SIMD3(repeating: .greatestFiniteMagnitude) + var upper = SIMD3(repeating: -.greatestFiniteMagnitude) + vertices.withUnsafeBytes { raw in + for vertex in 0 ..< vertexCount { + let record = vertex * UntoldLODMesh.vertexStride + let point = SIMD3( + Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record, as: UInt32.self))), + Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 4, as: UInt32.self))), + Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 8, as: UInt32.self))) + ) + lower = simd_min(lower, point) + upper = simd_max(upper, point) + } + } + return UntoldLODMeshLevel( + vertexData: vertices, + vertexCount: vertexCount, + indices: ordered, + boundsMin: vertexCount > 0 ? lower : mesh.boundsMin, + boundsMax: vertexCount > 0 ? upper : mesh.boundsMax, + error: error + ) + } +} diff --git a/Sources/UntoldEngineMeshCook/UntoldMeshLODWriter.swift b/Sources/UntoldEngineMeshCook/UntoldMeshLODWriter.swift new file mode 100644 index 000000000..f16651228 --- /dev/null +++ b/Sources/UntoldEngineMeshCook/UntoldMeshLODWriter.swift @@ -0,0 +1,196 @@ +// +// UntoldMeshLODWriter.swift +// UntoldEngineMeshCook +// +// Writes one level of a model's LOD chain as a `.untold` file of its own. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Compression +import Foundation +import UntoldEngine + +/// A level file is the source model with its geometry replaced: the same entities, mesh +/// records, materials and texture references, in the same order, so a loader can pair +/// the meshes of a level with those of the model by entity and position. The string, +/// entity, material and texture tables are copied byte for byte; feature edges, lights, +/// cameras and colour management stay with the model. +enum UntoldMeshLODWriter { + private struct ChunkPayload { + var entry: UntoldChunkEntryV1 + var storedBytes: Data + } + + /// The file for `levels`, one per mesh record of `source`, in the records' order. + static func levelFile(source: UntoldDecodedAsset, sourceData: Data, levels: [UntoldLODMeshLevel]) throws -> Data { + precondition(levels.count == source.meshes.count, "one level per mesh record") + + var vertexChunk = Data() + var indexChunk = Data() + let meshTable = UntoldBinaryWriter() + for (record, level) in zip(source.meshes, levels) { + let usesShortIndices = level.vertexCount <= Int(UInt16.max) + 1 + let vertexOffset = vertexChunk.count + let indexOffset = indexChunk.count + vertexChunk.append(level.vertexData) + if usesShortIndices { + level.indices.withUnsafeBufferPointer { indices in + var short = [UInt16](repeating: 0, count: indices.count) + for position in 0 ..< indices.count { + short[position] = UInt16(truncatingIfNeeded: indices[position]).littleEndian + } + short.withUnsafeBufferPointer { indexChunk.append($0) } + } + } else { + level.indices.withUnsafeBufferPointer { indices in + if UInt32(1).littleEndian == 1 { + indexChunk.append(indices) + } else { + indices.map(\.littleEndian).withUnsafeBufferPointer { indexChunk.append($0) } + } + } + } + + var updated = record + updated.indexType = usesShortIndices ? .uint16 : .uint32 + updated.vertexCount = UInt32(level.vertexCount) + updated.indexCount = UInt32(level.indices.count) + updated.vertexDataOffset = UInt64(vertexOffset) + updated.indexDataOffset = UInt64(indexOffset) + updated.vertexDataSizeBytes = UInt64(level.vertexData.count) + updated.indexDataSizeBytes = UInt64(indexChunk.count - indexOffset) + updated.estimatedGPUBytes = updated.vertexDataSizeBytes + updated.indexDataSizeBytes + // No feature edges: they outline the full model in the editor's wireframe. + updated.reserved0 = 0 + updated.localBounds = UntoldAABB(min: level.boundsMin, max: level.boundsMax) + updated.encode(to: meshTable) + } + + // The geometry chunks follow the model's choice of compression. + let compressGeometry = source.chunks.first(where: { $0.chunkType == .vertexData })?.compressionType == .lz4 + var payloads: [ChunkPayload] = [] + for chunkType in [UntoldChunkType.stringTable, .entityTable] { + try payloads.append(copiedPayload(chunkType, source: source, sourceData: sourceData)) + } + payloads.append(ChunkPayload( + entry: UntoldChunkEntryV1( + chunkType: .meshTable, + fileOffset: 0, + compressedSize: UInt64(meshTable.count), + uncompressedSize: UInt64(meshTable.count), + elementCount: UInt32(source.meshes.count) + ), + storedBytes: meshTable.data + )) + for chunkType in [UntoldChunkType.materialTable, .textureTable] { + try payloads.append(copiedPayload(chunkType, source: source, sourceData: sourceData)) + } + payloads.append(geometryPayload(.vertexData, bytes: vertexChunk, compress: compressGeometry)) + payloads.append(geometryPayload(.indexData, bytes: indexChunk, compress: compressGeometry)) + + var header = source.header + header.fileType = .lod + header.flags |= UntoldFileFlags.generatedLODLevel + header.chunkCount = UInt32(payloads.count) + let headerSize = encodedSize(of: header) + let entrySize = encodedSize(of: UntoldChunkEntryV1(chunkType: .stringTable, fileOffset: 0, compressedSize: 0, uncompressedSize: 0)) + let alignment = Int(UntoldFormat.fileAlignment) + + var offset = headerSize + entrySize * payloads.count + for index in payloads.indices { + offset = aligned(offset, to: alignment) + payloads[index].entry.fileOffset = UInt64(offset) + payloads[index].entry.compressedSize = UInt64(payloads[index].storedBytes.count) + offset += payloads[index].storedBytes.count + } + + let file = UntoldBinaryWriter() + header.encode(to: file) + for payload in payloads { + payload.entry.encode(to: file) + } + for payload in payloads { + file.align(to: alignment) + file.writeData(payload.storedBytes) + } + + // The hash covers the payloads alone, so the header is filled in once they are laid out. + var data = file.data + header.contentHash = try Array(UntoldFormat.contentHash(of: payloads.map(\.entry), in: data)) + let headerWriter = UntoldBinaryWriter() + header.encode(to: headerWriter) + data.replaceSubrange(0 ..< headerSize, with: headerWriter.data) + return data + } + + /// A chunk of the model as it is stored, compression included. + private static func copiedPayload(_ chunkType: UntoldChunkType, source: UntoldDecodedAsset, sourceData: Data) throws -> ChunkPayload { + guard let entry = source.chunks.first(where: { $0.chunkType == chunkType }) else { + throw UntoldValidationError.missingRequiredChunk(chunkType) + } + guard entry.fileOffset <= UInt64(sourceData.count), + entry.compressedSize <= UInt64(sourceData.count) - entry.fileOffset + else { + throw UntoldBinaryDecodingError.outOfBounds( + offset: Int(clamping: entry.fileOffset), + requested: Int(clamping: entry.compressedSize), + available: sourceData.count + ) + } + let start = sourceData.startIndex + Int(entry.fileOffset) + return ChunkPayload(entry: entry, storedBytes: sourceData.subdata(in: start ..< start + Int(entry.compressedSize))) + } + + private static func geometryPayload(_ chunkType: UntoldChunkType, bytes: Data, compress: Bool) -> ChunkPayload { + var entry = UntoldChunkEntryV1( + chunkType: chunkType, + fileOffset: 0, + compressedSize: UInt64(bytes.count), + uncompressedSize: UInt64(bytes.count) + ) + if compress, let compressed = lz4Compressed(bytes), compressed.count < bytes.count { + entry.compressionType = .lz4 + entry.compressedSize = UInt64(compressed.count) + return ChunkPayload(entry: entry, storedBytes: compressed) + } + return ChunkPayload(entry: entry, storedBytes: bytes) + } + + /// `bytes` as a raw LZ4 block, the form the exporter writes and `UntoldReader` decodes. + private static func lz4Compressed(_ bytes: Data) -> Data? { + guard !bytes.isEmpty else { return nil } + // Incompressible input grows slightly; a result that does not fit is not wanted anyway. + var output = Data(count: bytes.count) + let written = output.withUnsafeMutableBytes { destination in + bytes.withUnsafeBytes { source in + compression_encode_buffer( + destination.baseAddress!.assumingMemoryBound(to: UInt8.self), + destination.count, + source.baseAddress!.assumingMemoryBound(to: UInt8.self), + source.count, + nil, + COMPRESSION_LZ4_RAW + ) + } + } + guard written > 0 else { return nil } + output.removeSubrange(written ..< output.count) + return output + } + + private static func encodedSize(of value: some UntoldBinaryEncodable) -> Int { + let writer = UntoldBinaryWriter() + value.encode(to: writer) + return writer.count + } + + private static func aligned(_ value: Int, to alignment: Int) -> Int { + let remainder = value % alignment + return remainder == 0 ? value : value + (alignment - remainder) + } +} diff --git a/Sources/UntoldEngineMeshCook/UntoldPackLODCooker.swift b/Sources/UntoldEngineMeshCook/UntoldPackLODCooker.swift new file mode 100644 index 000000000..8c5ac621c --- /dev/null +++ b/Sources/UntoldEngineMeshCook/UntoldPackLODCooker.swift @@ -0,0 +1,180 @@ +// +// UntoldPackLODCooker.swift +// UntoldEngineMeshCook +// +// Builds the LOD chains of the models of a cooked `.untoldpack` and records them in +// its manifest. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import UntoldEngine + +/// What `UntoldMeshLODCooker.cookChains(forPackAt:)` did. +public struct UntoldPackLODReport: Sendable, Equatable { + public struct Failure: Sendable, Equatable { + /// The model's path as the manifest writes it. + public var path: String + public var reason: String + } + + /// Distinct `.untold` files the manifest places. + public var modelCount = 0 + /// Models that got at least one level. + public var chainCount = 0 + public var levelCount = 0 + /// Triangles of the models that got a chain. + public var chainedTriangleCount = 0 + /// Triangles of the coarsest level of each chain. + public var coarsestTriangleCount = 0 + /// Bytes of the level files written. + public var levelBytes: Int64 = 0 + /// Models left without a chain because a file that is not one of their levels has a + /// level's name (`UntoldMeshLODChain.SkipReason.levelNameTaken`), by manifest path. + public var blockedPaths: [String] = [] + /// Models that could not be read or written; they keep working without a chain. + public var failures: [Failure] = [] +} + +public extension UntoldMeshLODCooker { + /// The manifest key the chains are recorded under: for each model path, its levels + /// from the finest, each with the `path` of its file (relative to the manifest, like + /// the model's), its `screenSize`, its `triangles` and the simplifier's `error`. + static let packManifestKey = "lodChains" + + /// Builds a LOD chain for every distinct model of the pack at `packURL` (see + /// `cookChain(forModelAt:options:)`) and rewrites the manifest with them. A model + /// placed many times is simplified once. The rest of the manifest is kept as it is. + /// + /// `progress` is called after each model with the number done and the total; it can + /// be called from any thread, one call at a time. + @discardableResult + static func cookChains( + forPackAt packURL: URL, + options: UntoldMeshLODOptions = UntoldMeshLODOptions(), + progress: (@Sendable (_ done: Int, _ total: Int) -> Void)? = nil + ) throws -> UntoldPackLODReport { + try options.validate() + + var manifest: [String: Any] + do { + let data = try Data(contentsOf: packURL) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw UntoldMeshLODError.unreadableManifest(path: packURL.path, reason: "the manifest is not a JSON object") + } + manifest = object + } catch let error as UntoldMeshLODError { + throw error + } catch { + throw UntoldMeshLODError.unreadableManifest(path: packURL.path, reason: error.localizedDescription) + } + guard let models = manifest["models"] as? [[String: Any]] else { + throw UntoldMeshLODError.unreadableManifest(path: packURL.path, reason: "the manifest has no models") + } + + var seen = Set() + let paths = models.compactMap { $0["path"] as? String }.filter { seen.insert($0).inserted } + let packDirectory = packURL.deletingLastPathComponent() + + let results = ChainResults(count: paths.count) + DispatchQueue.concurrentPerform(iterations: paths.count) { index in + let modelURL = packDirectory.appendingPathComponent(paths[index]) + let result = Result { try cookChain(forModelAt: modelURL, options: options) } + let done = results.set(result, at: index) + if let progress { + results.report { progress(done, paths.count) } + } + } + + var report = UntoldPackLODReport() + report.modelCount = paths.count + var chains: [String: Any] = [:] + for (path, result) in zip(paths, results.all()) { + switch result { + case let .success(chain): + if chain.skipped == .levelNameTaken { + report.blockedPaths.append(path) + } + guard let coarsest = chain.levels.last else { continue } + report.chainCount += 1 + report.levelCount += chain.levels.count + report.chainedTriangleCount += chain.triangleCount + report.coarsestTriangleCount += coarsest.triangleCount + let directory = (path as NSString).deletingLastPathComponent + chains[path] = chain.levels.map { level -> [String: Any] in + report.levelBytes += fileSize(level.url) + return [ + "path": (directory as NSString).appendingPathComponent(level.url.lastPathComponent), + "triangles": level.triangleCount, + "screenSize": rounded(level.screenSize), + "error": rounded(level.error), + ] + } + case let .failure(error): + report.failures.append(.init(path: path, reason: String(describing: error))) + case nil: + report.failures.append(.init(path: path, reason: "not processed")) + } + } + + if chains.isEmpty { + manifest.removeValue(forKey: packManifestKey) + } else { + manifest[packManifestKey] = chains + } + do { + let data = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + try data.write(to: packURL, options: .atomic) + } catch { + throw UntoldMeshLODError.writeFailed(path: packURL.path, reason: error.localizedDescription) + } + return report + } + + /// Five significant digits are plenty for a switch point, and keep the manifest readable. + private static func rounded(_ value: Float) -> Double { + guard value.isFinite, value != 0 else { return 0 } + let magnitude = pow(10, 4 - floor(log10(abs(Double(value))))) + return (Double(value) * magnitude).rounded() / magnitude + } + + private static func fileSize(_ url: URL) -> Int64 { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return (attributes?[.size] as? NSNumber)?.int64Value ?? 0 + } +} + +/// The chains of a pack's models, filled in from several threads. +private final class ChainResults: @unchecked Sendable { + private let lock = NSLock() + private let reportLock = NSLock() + private var results: [Result?] + private var done = 0 + + init(count: Int) { + results = Array(repeating: nil, count: count) + } + + /// Stores a result and returns how many are in. + func set(_ result: Result, at index: Int) -> Int { + lock.withLock { + results[index] = result + done += 1 + return done + } + } + + func all() -> [Result?] { + lock.withLock { results } + } + + /// Runs one progress callback at a time. + func report(_ body: () -> Void) { + reportLock.withLock(body) + } +} diff --git a/Tests/UntoldEngineRenderTests/BaseColorShadingTests.swift b/Tests/UntoldEngineRenderTests/BaseColorShadingTests.swift new file mode 100644 index 000000000..5f5021422 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/BaseColorShadingTests.swift @@ -0,0 +1,115 @@ +// +// BaseColorShadingTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import MetalKit +import simd +@testable import UntoldEngine +import XCTest + +/// The base color a material gives, with and without a texture. +final class BaseColorShadingTests: MaterialShadingTestCase { + private func material(base: simd_float3, texture: URL? = nil, alpha: Float = 1.0, blended: Bool = false) -> RuntimeMaterialSource { + RuntimeMaterialSource( + baseColorFactor: simd_float4(base, alpha), + metallicFactor: 0.0, + roughnessFactor: 1.0, + flags: blended ? 2 : 0, + baseColorTexture: texture.map { RuntimeTextureReference(name: $0.lastPathComponent, sourceURL: $0, isSRGB: true) } + ) + } + + /// A base color of zero used to be taken for "not set" and drawn white: a black + /// oven came out as a white one. + func testABlackBaseColorIsDrawnBlack() throws { + let white = try shade(material(base: simd_float3(1, 1, 1))) + let black = try shade(material(base: simd_float3(0, 0, 0))) + XCTAssertGreaterThan(white.y, 0.1, "the face is lit") + XCTAssertLessThan(black.y, white.y * 0.05) + } + + func testABlackBlendedMaterialIsDrawnBlack() throws { + // Half see-through over the background: the darker the surface, the darker the pixel. + let white = try shade(material(base: simd_float3(1, 1, 1), alpha: 0.5, blended: true)) + let black = try shade(material(base: simd_float3(0, 0, 0), alpha: 0.5, blended: true)) + XCTAssertLessThan(black.y, white.y * 0.8) + } + + /// With a texture, a base color factor left at zero still means "show the texture". + func testATexturedMaterialWithAnUnsetBaseColorShowsItsTexture() throws { + let texture = try writeTexture(red: 200, green: 100, blue: 50) + let tinted = try shade(material(base: simd_float3(1, 1, 1), texture: texture)) + let unset = try shade(material(base: simd_float3(0, 0, 0), texture: texture)) + XCTAssertGreaterThan(tinted.x, 0.05) + for channel in 0 ..< 3 { + XCTAssertEqual(unset[channel], tinted[channel], accuracy: tinted[channel] * 0.03) + } + XCTAssertGreaterThan(tinted.x, tinted.z * 1.5, "the texture's orange shows") + } + + /// A color texture whose red, green and blue are the same is stored with one channel. + /// It was read as linear values where a color texture holds sRGB ones: a mid gray + /// came out more than twice as bright as the same gray stored with three channels. + func testAGrayscaleColorTextureIsAsBrightAsTheSameGrayInThreeChannels() throws { + let threeChannels = try shade(material(base: simd_float3(1, 1, 1), texture: writeTexture(red: 128, green: 128, blue: 128))) + let oneChannel = try shade(material(base: simd_float3(1, 1, 1), texture: writeGrayscaleTexture(value: 128))) + XCTAssertGreaterThan(threeChannels.y, 0.01, "the face is lit") + for channel in 0 ..< 3 { + XCTAssertEqual(oneChannel[channel], threeChannels[channel], accuracy: threeChannels[channel] * 0.03) + } + } + + /// The texture comes back in the pixel format of what it holds, so that whatever reads + /// it decodes it the same way: the shaders, and the texture streaming system when it + /// builds a smaller copy (it loads through the same function). + func testAGrayscaleTextureLoadsInTheFormatOfWhatItHolds() throws { + let loader = MTKTextureLoader(device: renderInfo.device) + func options(isSRGB: Bool) -> [MTKTextureLoader.Option: Any] { + [ + .textureUsage: NSNumber(value: MTLTextureUsage([.shaderRead, .pixelFormatView]).rawValue), + .textureStorageMode: NSNumber(value: MTLStorageMode.private.rawValue), + .SRGB: NSNumber(value: isSRGB), + .generateMipmaps: NSNumber(value: true), + ] + } + let gray = try writeGrayscaleTexture(value: 128) + + let colors = try XCTUnwrap(loadGrayscaleTextureAsRGBA(url: gray, isSRGB: true, loader: loader, options: options(isSRGB: true))) + XCTAssertEqual(colors.pixelFormat, .rgba8Unorm_srgb) + XCTAssertGreaterThan(colors.mipmapLevelCount, 1) + + let values = try XCTUnwrap(loadGrayscaleTextureAsRGBA(url: gray, isSRGB: false, loader: loader, options: options(isSRGB: false))) + XCTAssertEqual(values.pixelFormat, .rgba8Unorm) + + let threeChannels = try writeTexture(red: 128, green: 128, blue: 128) + XCTAssertNil( + loadGrayscaleTextureAsRGBA(url: threeChannels, isSRGB: true, loader: loader, options: options(isSRGB: true)), + "an image with three channels is left to the ordinary load" + ) + } + + /// A texture of values, not of colors, is read as it is stored, with one channel or three. + func testAGrayscaleValueTextureIsReadAsStored() throws { + func rough(_ texture: URL) -> RuntimeMaterialSource { + RuntimeMaterialSource( + baseColorFactor: simd_float4(1, 1, 1, 1), + metallicFactor: 1.0, + roughnessFactor: 1.0, + metallicTexture: RuntimeTextureReference(name: texture.lastPathComponent, sourceURL: texture), + roughnessTexture: RuntimeTextureReference(name: texture.lastPathComponent, sourceURL: texture) + ) + } + let threeChannels = try shade(rough(writeTexture(red: 128, green: 128, blue: 128))) + let oneChannel = try shade(rough(writeGrayscaleTexture(value: 128))) + XCTAssertGreaterThan(threeChannels.y, 0.01, "the face is lit") + for channel in 0 ..< 3 { + XCTAssertEqual(oneChannel[channel], threeChannels[channel], accuracy: threeChannels[channel] * 0.03) + } + } +} diff --git a/Tests/UntoldEngineRenderTests/EmissiveShadingTests.swift b/Tests/UntoldEngineRenderTests/EmissiveShadingTests.swift new file mode 100644 index 000000000..7937cdd60 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/EmissiveShadingTests.swift @@ -0,0 +1,113 @@ +// +// EmissiveShadingTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import ModelIO +import simd +@testable import UntoldEngine +import XCTest + +/// The light a material gives off: its emissive color, its emissive texture, and both +/// on a material that is blended over what is behind it. +final class EmissiveShadingTests: MaterialShadingTestCase { + /// A black surface, so that all it shows is what it gives off. + private func material(emissive: simd_float3, texture: URL? = nil, alpha: Float = 1.0, blended: Bool = false) -> RuntimeMaterialSource { + RuntimeMaterialSource( + baseColorFactor: simd_float4(0, 0, 0, alpha), + emissiveFactor: emissive, + metallicFactor: 0.0, + roughnessFactor: 1.0, + flags: blended ? 2 : 0, + emissiveTexture: texture.map { RuntimeTextureReference(name: $0.lastPathComponent, sourceURL: $0, isSRGB: true) } + ) + } + + /// How much of a glow a cube that is half there shows. The glow fades with the + /// surface, so its front face gives half of it; a blended cube also shows its back + /// face, which gives half again, seen through the front: 0.5 + 0.5 x 0.5. + private static let halfThereTwice: Float = 0.75 + + /// The 8-bit value of an sRGB texture, as the linear value a shader reads from it. + private func linear(_ value: UInt8) -> Float { + let encoded = Float(value) / 255.0 + return encoded <= 0.04045 ? encoded / 12.92 : pow((encoded + 0.055) / 1.055, 2.4) + } + + /// An emissive texture was loaded and never drawn: a lit sign showed the flat color + /// of its emissive factor, white, in place of what is painted on it. + func testAnEmissiveTextureColorsTheGlow() throws { + try buildScene(towardsLight: nil) + let dark = try shade(material(emissive: .zero)) + let plain = try shade(material(emissive: simd_float3(1, 1, 1))) + let texture = try writeTexture(red: 200, green: 100, blue: 50) + let painted = try shade(material(emissive: simd_float3(1, 1, 1), texture: texture)) + + XCTAssertEqual(plain.x - dark.x, 1.0, accuracy: 0.02, "without a texture, the emissive color is what glows") + let expected = simd_float3(linear(200), linear(100), linear(50)) + for channel in 0 ..< 3 { + XCTAssertEqual(painted[channel] - dark[channel], expected[channel], accuracy: 0.02) + } + } + + func testTheEmissiveColorScalesTheTexture() throws { + try buildScene(towardsLight: nil) + let dark = try shade(material(emissive: .zero)) + let texture = try writeTexture(red: 200, green: 100, blue: 50) + let full = try shade(material(emissive: simd_float3(1, 1, 1), texture: texture)) - dark + let half = try shade(material(emissive: simd_float3(0.5, 0.5, 0.5), texture: texture)) - dark + let twice = try shade(material(emissive: simd_float3(2, 2, 2), texture: texture)) - dark + let none = try shade(material(emissive: .zero, texture: texture)) - dark + + XCTAssertGreaterThan(full.x, 0.3) + XCTAssertEqual(half.x, full.x * 0.5, accuracy: 0.02) + XCTAssertEqual(twice.x, full.x * 2.0, accuracy: 0.03) + XCTAssertEqual(none.x, 0.0, accuracy: 0.01, "an emissive color of zero gives off nothing, with or without a texture") + } + + /// A blended material gave off nothing: a lit, see-through panel was drawn unlit. + func testABlendedMaterialGlows() throws { + try buildScene(towardsLight: nil) + let dark = try shade(material(emissive: .zero, alpha: 0.5, blended: true)) + let glowing = try shade(material(emissive: simd_float3(1.0, 0.5, 0.0), alpha: 0.5, blended: true)) + + for channel in 0 ..< 3 { + XCTAssertEqual(glowing[channel] - dark[channel], simd_float3(1.0, 0.5, 0.0)[channel] * Self.halfThereTwice, accuracy: 0.02) + } + } + + func testABlendedMaterialShowsItsEmissiveTexture() throws { + try buildScene(towardsLight: nil) + let dark = try shade(material(emissive: .zero, alpha: 0.5, blended: true)) + let texture = try writeTexture(red: 50, green: 100, blue: 200) + let painted = try shade(material(emissive: simd_float3(1, 1, 1), texture: texture, alpha: 0.5, blended: true)) + + let expected = simd_float3(linear(50), linear(100), linear(200)) * Self.halfThereTwice + for channel in 0 ..< 3 { + XCTAssertEqual(painted[channel] - dark[channel], expected[channel], accuracy: 0.02) + } + } + + /// A material read from a USD file has the texture and no color to go with it: it + /// shows the texture as painted. + func testAUSDMaterialWithAnEmissiveTextureGlowsWithIt() throws { + let texture = try writeTexture(red: 200, green: 100, blue: 50) + let withTexture = MDLMaterial(name: "sign", scatteringFunction: MDLPhysicallyPlausibleScatteringFunction()) + withTexture.setProperty(MDLMaterialProperty(name: "emission", semantic: .emission, url: texture)) + let without = MDLMaterial(name: "wall", scatteringFunction: MDLPhysicallyPlausibleScatteringFunction()) + + let textureLoader = TextureLoader(device: renderInfo.device) + let sign = Material(mdlMaterial: withTexture, textureLoader: textureLoader, name: "sign") + let wall = Material(mdlMaterial: without, textureLoader: textureLoader, name: "wall") + + XCTAssertTrue(sign.hasEmissiveMap) + XCTAssertEqual(sign.emissiveValue, simd_float3(1, 1, 1)) + XCTAssertFalse(wall.hasEmissiveMap) + XCTAssertEqual(wall.emissiveValue, .zero) + } +} diff --git a/Tests/UntoldEngineRenderTests/EnvironmentAssetRebakeTests.swift b/Tests/UntoldEngineRenderTests/EnvironmentAssetRebakeTests.swift index 5c4dd844a..33b811cbc 100644 --- a/Tests/UntoldEngineRenderTests/EnvironmentAssetRebakeTests.swift +++ b/Tests/UntoldEngineRenderTests/EnvironmentAssetRebakeTests.swift @@ -1,3 +1,13 @@ +// +// EnvironmentAssetRebakeTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation @testable import UntoldEngine import XCTest diff --git a/Tests/UntoldEngineRenderTests/EnvironmentReflectionShadingTests.swift b/Tests/UntoldEngineRenderTests/EnvironmentReflectionShadingTests.swift new file mode 100644 index 000000000..7f2ae699a --- /dev/null +++ b/Tests/UntoldEngineRenderTests/EnvironmentReflectionShadingTests.swift @@ -0,0 +1,207 @@ +// +// EnvironmentReflectionShadingTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// What a surface reflects of the environment around it, with no other light on it. +final class EnvironmentReflectionShadingTests: MaterialShadingTestCase { + private typealias Frame = (pixels: [simd_float3], width: Int, height: Int) + + private func material(base: simd_float3 = simd_float3(1, 1, 1), metallic: Float, roughness: Float) -> RuntimeMaterialSource { + RuntimeMaterialSource( + baseColorFactor: simd_float4(base, 1.0), + metallicFactor: metallic, + roughnessFactor: roughness + ) + } + + /// A sphere lit by the environment alone. + private func buildEnvironmentScene() throws { + try buildScene(.sphere, towardsLight: nil) + applyIBL = true + ambientIntensity = 1.0 + } + + private static func brightness(_ color: simd_float3) -> Float { + simd_dot(color, simd_float3(0.2126, 0.7152, 0.0722)) + } + + /// How much fine detail the middle of the frame shows: the mean step in brightness + /// from a pixel to the next, as a share of the mean brightness. A mirror image of a + /// busy room is full of steps; a blurred one has next to none. + private static func detail(of frame: Frame, window: Int = 160) -> Float { + let left = frame.width / 2 - window / 2 + let top = frame.height / 2 - window / 2 + var steps: Float = 0 + var total: Float = 0 + for row in top ..< top + window { + for column in left ..< left + window { + let here = brightness(frame.pixels[row * frame.width + column]) + let right = brightness(frame.pixels[row * frame.width + column + 1]) + let below = brightness(frame.pixels[(row + 1) * frame.width + column]) + steps += abs(right - here) + abs(below - here) + total += here + } + } + return total > 0 ? steps / total : 0 + } + + /// The environment is filtered once per roughness when it is loaded, and the rougher + /// levels were never read: a rough metal reflected the room as sharply as a mirror. + func testARoughMetalBlursWhatItReflects() throws { + try buildEnvironmentScene() + let polished = try Self.detail(of: shadeFrame(material(metallic: 1.0, roughness: 0.05))) + let satin = try Self.detail(of: shadeFrame(material(metallic: 1.0, roughness: 0.5))) + let rough = try Self.detail(of: shadeFrame(material(metallic: 1.0, roughness: 0.9))) + + XCTAssertGreaterThan(polished, 0.02, "a polished metal shows the room in detail") + XCTAssertLessThan(satin, polished * 0.6) + XCTAssertLessThan(rough, polished * 0.25) + } + + /// The table that tells how much of the environment a surface reflects was read + /// upside down: a polished metal took the share of a rough one, about a third, and + /// its reflections came out that dark. + func testAPolishedMetalReflectsAllOfTheEnvironment() throws { + try buildScene(.sphere, towardsLight: nil) + try lightWithAnEvenEnvironment() + let all = try shade(material(metallic: 0.0, roughness: 1.0)).y + let polished = try shade(material(metallic: 1.0, roughness: 0.05)).y + + XCTAssertGreaterThan(all, 0.05, "a matte white gives back the light of the environment") + XCTAssertEqual(polished, all, accuracy: all * 0.05) + } + + /// A rough surface bounces part of the light between its own bumps before it lets it + /// go. The table counts one bounce, so it took that part for lost: the roughest white + /// metal gave back a third of the light around it. + func testARoughMetalGivesBackTheLightItReflects() throws { + try buildScene(.sphere, towardsLight: nil) + try lightWithAnEvenEnvironment() + let all = try shade(material(metallic: 0.0, roughness: 1.0)).y + let satin = try shade(material(metallic: 1.0, roughness: 0.5)).y + let rough = try shade(material(metallic: 1.0, roughness: 1.0)).y + + XCTAssertGreaterThan(all, 0.05) + XCTAssertEqual(satin, all, accuracy: all * 0.05) + XCTAssertEqual(rough, all, accuracy: all * 0.05) + } + + /// A colored metal tints every bounce: the light that leaves a rough one after + /// several is deeper in color, never brighter than its own color allows. + func testARoughColoredMetalStaysWithinItsColor() throws { + try buildScene(.sphere, towardsLight: nil) + try lightWithAnEvenEnvironment() + let all = try shade(material(metallic: 0.0, roughness: 1.0)) + let gold = simd_float3(1.0, 0.77, 0.34) + let polished = try shade(material(base: gold, metallic: 1.0, roughness: 0.05)) + let rough = try shade(material(base: gold, metallic: 1.0, roughness: 1.0)) + + for channel in 0 ..< 3 { + XCTAssertEqual(polished[channel], all[channel] * gold[channel], accuracy: all[channel] * 0.05) + XCTAssertLessThanOrEqual(rough[channel], all[channel] * gold[channel] * 1.02) + } + XCTAssertGreaterThan(rough.x, all.x * 0.9, "the channel the metal reflects in full keeps its light") + XCTAssertLessThan(rough.z / rough.x, polished.z / polished.x, "the color deepens with the bounces") + } + + /// The mean brightness of the middle of the sphere, where it faces the camera. + private static func facingBrightness(of frame: Frame) -> Float { + brightness(meanColor(of: frame, aroundX: frame.width / 2, y: frame.height / 2, window: 32)) + } + + /// The mean brightness near the sphere's outline, where it is seen at a slant: four + /// spots at nine tenths of the way out, to the left, the right, above and below. + private static func slantedBrightness(of frame: Frame) -> Float { + // The sphere is 2 across and 5 away, under a 65 degree lens. + let radius = Float(frame.height) / (2.0 * 5.0 * tan(65.0 / 2.0 * .pi / 180.0)) * 1.02 + let offset = Int(radius * 0.9) + let spots = [(-offset, 0), (offset, 0), (0, -offset), (0, offset)] + return spots.reduce(Float(0)) { sum, spot in + sum + brightness(meanColor(of: frame, aroundX: frame.width / 2 + spot.0, y: frame.height / 2 + spot.1, window: 8)) + } / Float(spots.count) + } + + /// Only metals reflected the environment: polished plastic, glass and lacquer came + /// out matte, and a black one came out with no light on it at all. + func testAPolishedNonMetalReflectsTheEnvironment() throws { + try buildScene(.sphere, towardsLight: nil) + try lightWithAnEvenEnvironment() + let all = try shade(material(metallic: 0.0, roughness: 1.0)).y + let lacquer = try shadeFrame(material(base: .zero, metallic: 0.0, roughness: 0.05)) + + // Head on, a non-metal reflects 4 % of the light; at a slant, much more: the + // bright rim of a polished ball. + let facing = Self.facingBrightness(of: lacquer) + let slanted = Self.slantedBrightness(of: lacquer) + XCTAssertEqual(facing, all * 0.04, accuracy: all * 0.005) + XCTAssertGreaterThan(slanted, facing * 2.0) + XCTAssertLessThan(slanted, all * 0.5) + } + + func testANonMetalReflectsAnImageOfTheRoom() throws { + try buildEnvironmentScene() + let polished = try Self.detail(of: shadeFrame(material(base: .zero, metallic: 0.0, roughness: 0.05))) + let rough = try Self.detail(of: shadeFrame(material(base: .zero, metallic: 0.0, roughness: 0.9))) + + XCTAssertGreaterThan(polished, 0.02, "a polished black surface shows the room in detail") + XCTAssertLessThan(rough, polished * 0.25, "and a rough one a blur of it") + } + + /// What a surface reflects, it does not also scatter: a white one gives back all the + /// light around it and no more, whatever its finish, metal or not. + func testAWhiteSurfaceGivesBackAllTheLightWhateverItsFinish() throws { + try buildScene(.sphere, towardsLight: nil) + try lightWithAnEvenEnvironment() + let all = try shade(material(metallic: 1.0, roughness: 0.05)).y + XCTAssertGreaterThan(all, 0.05, "a polished white metal gives back the light of the environment") + + for metallic: Float in [0.0, 1.0] { + for roughness: Float in [0.05, 0.5, 1.0] { + let frame = try shadeFrame(material(metallic: metallic, roughness: roughness)) + XCTAssertEqual(Self.facingBrightness(of: frame), all, accuracy: all * 0.05, "metallic \(metallic), roughness \(roughness), head on") + XCTAssertEqual(Self.slantedBrightness(of: frame), all, accuracy: all * 0.05, "metallic \(metallic), roughness \(roughness), at a slant") + } + } + } + + /// A see-through cube shows the far side of its back face through its front. Seen + /// from behind, a surface reflects as its front does at that angle. It used to count + /// as seen edge on, where a smooth surface reflects nearly all the light. + func testASurfaceSeenFromBehindReflectsLikeItsFront() throws { + try buildScene(.cube, towardsLight: nil) + try lightWithAnEvenEnvironment() + let all = try shade(material(metallic: 0.0, roughness: 1.0)).y + let pane = try shade(RuntimeMaterialSource( + baseColorFactor: simd_float4(0, 0, 0, 0.5), + metallicFactor: 0.0, + roughnessFactor: 0.05, + flags: 2 + )).y + + // Half there: the front face shows half of its 4 %, and the back face half of + // that again through the front. + XCTAssertGreaterThan(all, 0.05) + XCTAssertEqual(pane, all * 0.04 * 0.75, accuracy: all * 0.01) + } + + /// A gray non-metal scatters its share of what it does not reflect. + func testAGrayNonMetalScattersWhatItDoesNotReflect() throws { + try buildScene(.sphere, towardsLight: nil) + try lightWithAnEvenEnvironment() + let all = try shade(material(metallic: 0.0, roughness: 1.0)).y + let gray = try Self.facingBrightness(of: shadeFrame(material(base: simd_float3(0.5, 0.5, 0.5), metallic: 0.0, roughness: 0.05))) + + // 4 % reflected, and half of the other 96 % scattered. + XCTAssertEqual(gray, all * (0.04 + 0.96 * 0.5), accuracy: all * 0.01) + } +} diff --git a/Tests/UntoldEngineRenderTests/GaussianStreamingTest.swift b/Tests/UntoldEngineRenderTests/GaussianStreamingTest.swift index 12fd6ac7a..5f20c48b8 100644 --- a/Tests/UntoldEngineRenderTests/GaussianStreamingTest.swift +++ b/Tests/UntoldEngineRenderTests/GaussianStreamingTest.swift @@ -200,6 +200,43 @@ final class GaussianStreamingTest: BaseRenderSetup { XCTAssertNotNil(scene.get(component: GaussianComponent.self, for: entity), "āŒ GaussianComponent should be attached after load") } + /// `.single(filename:)` with no `withExtension` must probe `.untoldgs` then `.ply` — + /// same convention as `setEntityMeshAsync`'s `.untoldpack`/`.untold` probing. Only + /// `test_gaussians.ply` exists in the test bundle, so this also proves the fallback leg, + /// not just that an extension was found at all. + func testSetEntityGaussianStreamable_probesPLYWhenExtensionOmitted() async { + let tileRoot = createEntity() + if let tileComp = scene.assign(to: tileRoot, component: TileComponent.self) { + tileComp.state = .parsed + } + if let tileLocal = scene.get(component: LocalTransformComponent.self, for: tileRoot) { + tileLocal.boundingBox = (min: simd_float3(-10, -10, -10), max: simd_float3(10, 10, 10)) + } + OctreeSystem.shared.registerEntity(tileRoot) + + let entity = createEntity() + translateTo(entityId: entity, position: .zero) + + setEntityGaussianTileStreaming( + entityId: entity, + source: .single(filename: "test_gaussians"), + options: GaussianStreamingOptions( + streamingRadius: 10.0, + unloadRadius: 20.0, + boundingBoxHalfExtent: simd_float3(1, 1, 1) + ) + ) + + let streaming = scene.get(component: StreamingComponent.self, for: entity) + XCTAssertEqual(streaming?.assetExtension, "ply", "āŒ Probing should have resolved the omitted extension to .ply and stored it concretely") + + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.1) + await scene.get(component: StreamingComponent.self, for: entity)?.loadTask?.value + + XCTAssertEqual(scene.get(component: StreamingComponent.self, for: entity)?.state, .loaded, "āŒ Entity should still load correctly once the probed extension is persisted") + XCTAssertNotNil(scene.get(component: GaussianComponent.self, for: entity), "āŒ GaussianComponent should be attached after load") + } + /// If no tile contains the entity's position, the entity should be left as a plain, /// non-streaming entity rather than crashing or silently half-configuring it. func testSetEntityGaussianTileStreaming_noContainingTileLeavesEntityNonStreaming() { @@ -571,6 +608,34 @@ final class GaussianStreamingTest: BaseRenderSetup { XCTAssertNil(scene.get(component: GaussianComponent.self, for: farEntity), "āŒ Evicted splat's GaussianComponent should be torn down") } + // MARK: - Extension-less probing (setEntityGaussian / setEntityGaussianAsync) + + /// `setEntityGaussian(entityId:filename:)` with no `withExtension` must probe + /// `.untoldgs` then `.ply`, same as the tile-streaming entry point. + func testSetEntityGaussian_probesPLYWhenExtensionOmitted() { + let entity = createEntity() + setEntityGaussian(entityId: entity, filename: "test_gaussians") + + XCTAssertNotNil(scene.get(component: GaussianComponent.self, for: entity), "āŒ Omitting withExtension should still resolve and load test_gaussians.ply synchronously") + XCTAssertEqual(scene.get(component: GaussianComponent.self, for: entity)?.sourceURL?.pathExtension, "ply") + } + + /// `setEntityGaussianAsync(entityId:filename:completion:)` with no `withExtension` must + /// also probe `.untoldgs` then `.ply`. + func testSetEntityGaussianAsync_probesPLYWhenExtensionOmitted() async { + let entity = createEntity() + let expectation = XCTestExpectation(description: "Gaussian async load completes") + var succeeded = false + setEntityGaussianAsync(entityId: entity, filename: "test_gaussians") { success in + succeeded = success + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: TimeInterval(timeoutFactor)) + + XCTAssertTrue(succeeded, "āŒ Omitting withExtension should still resolve and load test_gaussians.ply") + XCTAssertNotNil(scene.get(component: GaussianComponent.self, for: entity)) + } + // MARK: - markUsed correctness /// `executeGaussianFrustumCulling` must call `MemoryBudgetManager.markUsed` for entities diff --git a/Tests/UntoldEngineRenderTests/LightSystemTest.swift b/Tests/UntoldEngineRenderTests/LightSystemTest.swift index 600a91a34..d8acb54ea 100644 --- a/Tests/UntoldEngineRenderTests/LightSystemTest.swift +++ b/Tests/UntoldEngineRenderTests/LightSystemTest.swift @@ -31,12 +31,52 @@ final class LightSystemTest: BaseRenderSetup { _ value: simd_float3, equals expected: simd_float3, accuracy: Float = 0.001, + _ message: String = "", file: StaticString = #filePath, line: UInt = #line ) { - XCTAssertEqual(value.x, expected.x, accuracy: accuracy, file: file, line: line) - XCTAssertEqual(value.y, expected.y, accuracy: accuracy, file: file, line: line) - XCTAssertEqual(value.z, expected.z, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(value.x, expected.x, accuracy: accuracy, message, file: file, line: line) + XCTAssertEqual(value.y, expected.y, accuracy: accuracy, message, file: file, line: line) + XCTAssertEqual(value.z, expected.z, accuracy: accuracy, message, file: file, line: line) + } + + /// The four functions that make a light, with the name a failure reports. + private let lightKinds: [(name: String, create: (EntityID) -> Void)] = [ + ("directional", { createDirLight(entityId: $0) }), + ("point", { createPointLight(entityId: $0) }), + ("spot", { createSpotLight(entityId: $0) }), + ("area", { createAreaLight(entityId: $0) }), + ] + + /// A light is made without asking for a scene graph traversal, so the matrices of the + /// entities have to be the ones a traversal computes already: run one over them and + /// check that it moves nothing. + private func assertTraversalChangesNothing( + _ entities: [EntityID], + _ message: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + let worldBefore = entities.map { scene.get(component: WorldTransformComponent.self, for: $0)?.space } + + for entityId in entities { + scene.get(component: LocalTransformComponent.self, for: entityId)?.transformDirty = true + } + anyTransformDirty = true + traverseSceneGraph() + + for (entityId, before) in zip(entities, worldBefore) { + guard let before, let after = scene.get(component: WorldTransformComponent.self, for: entityId)?.space else { + XCTFail("\(message): entity \(entityId) has no world transform", file: file, line: line) + continue + } + for column in 0 ..< 4 { + XCTAssertLessThan( + simd_length(after[column] - before[column]), 0.0001, + "\(message): the traversal moved column \(column) of entity \(entityId)", file: file, line: line + ) + } + } } private func assertMatrixNotApproximatelyEqual( @@ -789,4 +829,142 @@ final class LightSystemTest: BaseRenderSetup { XCTAssertEqual(lightComponent.intensity, 1.0, "Intensity should be all 1's") destroyEntity(entityId: entityId) } + + // MARK: - Lights and the transform of their entity + + func testLightKeepsThePlaceOfItsEntityInTheScene() { + for kind in lightKinds { + let parent = createEntity() + let entity = createEntity() + let child = createEntity() + translateTo(entityId: parent, position: simd_float3(10.0, 0.0, 0.0)) + setParent(childId: entity, parentId: parent) + setParent(childId: child, parentId: entity) + translateTo(entityId: entity, position: simd_float3(1.0, 2.0, 3.0)) + scaleTo(entityId: entity, scale: simd_float3(2.0, 2.0, 2.0)) + translateTo(entityId: child, position: simd_float3(0.0, 0.0, 1.0)) + + kind.create(entity) + + assertVector(getLocalPosition(entityId: entity), equals: simd_float3(1.0, 2.0, 3.0), "\(kind.name): local position") + assertVector(getScale(entityId: entity), equals: simd_float3(2.0, 2.0, 2.0), "\(kind.name): scale") + assertVector(getPosition(entityId: entity), equals: simd_float3(11.0, 2.0, 3.0), "\(kind.name): world position") + + XCTAssertEqual(getEntityParent(entityId: entity), parent, "\(kind.name): the entity keeps its parent") + XCTAssertEqual(getEntityChildren(parentId: parent), [entity], "\(kind.name): the parent lists the entity once") + XCTAssertEqual(getEntityChildren(parentId: entity), [child], "\(kind.name): the entity keeps its child") + XCTAssertEqual(getEntityParent(entityId: child), entity, "\(kind.name): the child keeps its parent") + XCTAssertEqual(scene.get(component: ScenegraphComponent.self, for: entity)?.level, 1, "\(kind.name): level of the entity") + XCTAssertEqual(scene.get(component: ScenegraphComponent.self, for: child)?.level, 2, "\(kind.name): level of the child") + + // Nothing rotated the entity, so the light gets its default direction: down. + assertVector(getLightEmissionDirection(entityId: entity), equals: simd_float3(0.0, -1.0, 0.0), "\(kind.name): emission direction") + + assertTraversalChangesNothing([parent, entity, child], kind.name) + + destroyEntity(entityId: parent) + destroyEntity(entityId: entity) + destroyEntity(entityId: child) + } + } + + func testLightKeepsTheRotationOfAnEntityThatWasRotated() { + for kind in lightKinds { + let entity = createEntity() + let child = createEntity() + setParent(childId: child, parentId: entity) + translateTo(entityId: child, position: simd_float3(0.0, 0.0, 1.0)) + rotateTo(entityId: entity, angle: 90.0, axis: simd_float3(0.0, 1.0, 0.0)) + let rotation = getRotationQuaternion(entityId: entity) + + kind.create(entity) + + XCTAssertEqual( + abs(simd_dot(getRotationQuaternion(entityId: entity), rotation)), 1.0, accuracy: 0.0001, + "\(kind.name): the rotation set before the light was made" + ) + // Lights emit along local -Z, which a quarter turn around +Y points along -X. + assertVector(getLightEmissionDirection(entityId: entity), equals: simd_float3(-1.0, 0.0, 0.0), "\(kind.name): emission direction") + // The child stays where the rotation of its parent put it. + assertVector(getPosition(entityId: child), equals: simd_float3(1.0, 0.0, 0.0), "\(kind.name): world position of the child") + + assertTraversalChangesNothing([entity, child], kind.name) + + destroyEntity(entityId: entity) + destroyEntity(entityId: child) + } + } + + func testLightOnAFreshEntityReachesTheRendererWhereItIsMoved() throws { + destroyAllEntities() + // The renderer's own directional light stays the active one until it is gone. + finalizePendingDestroys() + + let position = simd_float3(4.0, 5.0, 6.0) + var lights: [String: EntityID] = [:] + for kind in lightKinds { + let entity = createEntity() + kind.create(entity) + translateTo(entityId: entity, position: position) + lights[kind.name] = entity + + XCTAssertTrue(hasComponent(entityId: entity, componentType: LightComponent.self), "\(kind.name): light component") + XCTAssertTrue(hasComponent(entityId: entity, componentType: RenderComponent.self), "\(kind.name): the handle the editor draws") + assertVector(getPosition(entityId: entity), equals: position, "\(kind.name): world position") + assertVector(getLightEmissionDirection(entityId: entity), equals: simd_float3(0.0, -1.0, 0.0), "\(kind.name): emission direction") + } + + XCTAssertEqual(LightingSystem.shared.activeDirectionalLight, lights["directional"]) + let sun = getDirectionalLightParameters() + assertVector(sun.direction, equals: simd_float3(0.0, 1.0, 0.0), "directional: towards the light") + XCTAssertEqual(sun.intensity, 1.0, accuracy: 0.0001) + + let point = try XCTUnwrap(getPointLights().first) + XCTAssertEqual(getPointLights().count, 1) + assertVector(point.position, equals: position, "point: position in the renderer's list") + + let spot = try XCTUnwrap(getSpotLights().first) + XCTAssertEqual(getSpotLights().count, 1) + assertVector(spot.position, equals: position, "spot: position in the renderer's list") + assertVector(spot.direction, equals: simd_float3(0.0, -1.0, 0.0), "spot: direction in the renderer's list") + + let area = try XCTUnwrap(getAreaLights().first) + XCTAssertEqual(getAreaLights().count, 1) + assertVector(area.position, equals: position, "area: position in the renderer's list") + assertVector(area.forward, equals: simd_float3(0.0, 1.0, 0.0), "area: front normal in the renderer's list") + } + + func testLightOnAFreshEntityAsksForNoSceneGraphTraversal() { + for kind in lightKinds { + // Settle what the scene asked for before, the renderer's own camera included. + traverseSceneGraph() + XCTAssertFalse(anyTransformDirty) + + let entity = createEntity() + kind.create(entity) + translateTo(entityId: entity, position: simd_float3(4.0, 5.0, 6.0)) + + XCTAssertFalse(anyTransformDirty, "\(kind.name): making a light asks for no traversal") + assertTraversalChangesNothing([entity], kind.name) + + destroyEntity(entityId: entity) + } + } + + func testLightGivesATransformToAnEntityThatHasNone() { + for kind in lightKinds { + // As entities were before createEntity() attached the components itself. + let entity = scene.newEntity() + XCTAssertFalse(hasComponent(entityId: entity, componentType: LocalTransformComponent.self)) + + kind.create(entity) + + XCTAssertTrue(hasComponent(entityId: entity, componentType: LocalTransformComponent.self), "\(kind.name): local transform") + XCTAssertTrue(hasComponent(entityId: entity, componentType: WorldTransformComponent.self), "\(kind.name): world transform") + XCTAssertTrue(hasComponent(entityId: entity, componentType: ScenegraphComponent.self), "\(kind.name): scene graph") + assertVector(getLightEmissionDirection(entityId: entity), equals: simd_float3(0.0, -1.0, 0.0), "\(kind.name): emission direction") + + destroyEntity(entityId: entity) + } + } } diff --git a/Tests/UntoldEngineRenderTests/LockCensusTests.swift b/Tests/UntoldEngineRenderTests/LockCensusTests.swift index ec3ba336f..a6d97904f 100644 --- a/Tests/UntoldEngineRenderTests/LockCensusTests.swift +++ b/Tests/UntoldEngineRenderTests/LockCensusTests.swift @@ -36,6 +36,9 @@ enum LockCensus { private typealias LockIMP = @convention(c) (AnyObject, Selector) -> Void + /// Replaces `-[NSLock lock]` and `-[NSRecursiveLock lock]` for the rest of the process. There is + /// no uninstall on purpose: with `enabled` false the hook costs one flag check per lock, and the + /// test that installs it is opt-in. static func install() { guard !installed else { return } installed = true @@ -84,12 +87,13 @@ enum LockCensus { for (frames, count) in snapshot.counts { // Skip the hook itself, its block thunk (`...TR`) and the Foundation `withLock` // helpers: the first two engine symbols are the function taking the lock and - // the one that called it. + // the one that called it. The thunk suffix is a detail of Swift's name mangling, + // so the test checks this attribution on a known lock before it reports. let names = frames.compactMap(symbol).filter { !$0.contains("LockCensus") && !$0.contains("NSLocking") && !$0.hasPrefix("__") && !$0.hasSuffix("TR") } let owner = names.first ?? "?" - let caller = names.dropFirst().first { $0 != owner } ?? "?" + let caller = names.dropFirst().first ?? "?" merged[owner + "\t" + caller, default: 0] += count } let rows = merged.map { key, count -> (owner: String, caller: String, count: Int) in @@ -118,9 +122,37 @@ enum LockCensus { } } +/// A lock taken a known number of times from `censusProbeOwner`, itself called from +/// `censusProbeCaller`: the census checks its owner and caller attribution against them before it +/// reports. Neither name may contain "LockCensus", which the report filters out as the hook's own. +private let censusProbeLock = NSLock() + +@inline(never) +private func censusProbeOwner(times: Int) -> Int { + var taken = 0 + for _ in 0 ..< times { + censusProbeLock.lock() + taken += 1 + censusProbeLock.unlock() + } + return taken +} + +@inline(never) +private func censusProbeCaller(times: Int) -> Int { + // The addition keeps the call out of tail position, so this frame stays on the stack in an + // optimised build. + censusProbeOwner(times: times) &+ 1 +} + final class LockCensusTests: BaseRenderSetup { + /// Same rule as the other benchmarks' helper: a missing, malformed, zero or negative value + /// falls back to the default. private func intEnv(_ name: String, default defaultValue: Int) -> Int { - ProcessInfo.processInfo.environment[name].flatMap(Int.init) ?? defaultValue + guard let raw = ProcessInfo.processInfo.environment[name], let value = Int(raw), value > 0 else { + return defaultValue + } + return value } func test_lockCensus_perFrame() throws { @@ -156,6 +188,24 @@ final class LockCensusTests: BaseRenderSetup { LockCensus.install() + // 0. Self-check. A lock taken a known number of times from a known function must come back + // attributed to that function and to its caller. If a toolchain changed how the hook's own + // frames are named, the report would otherwise blame the wrong functions without a sign. + let probeCount = 100 + LockCensus.reset() + LockCensus.enabled = true + _ = censusProbeCaller(times: probeCount) + LockCensus.enabled = false + let probeRows = LockCensus.report().rows.filter { $0.owner.contains("censusProbeOwner") } + XCTAssertEqual( + probeRows.reduce(0) { $0 + $1.count }, probeCount, + "the census did not attribute the probe's lock to the function that took it" + ) + XCTAssertTrue( + !probeRows.isEmpty && probeRows.allSatisfy { $0.caller.contains("censusProbeCaller") }, + "the census did not attribute the probe's lock to its caller: \(probeRows.map(\.caller))" + ) + // 1. The frame itself. LockCensus.reset() start = DispatchTime.now().uptimeNanoseconds diff --git a/Tests/UntoldEngineRenderTests/MaterialShadingTestCase.swift b/Tests/UntoldEngineRenderTests/MaterialShadingTestCase.swift new file mode 100644 index 000000000..48ea67668 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/MaterialShadingTestCase.swift @@ -0,0 +1,200 @@ +// +// MaterialShadingTestCase.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import ImageIO +import Metal +import simd +import UniformTypeIdentifiers +@testable import UntoldEngine +import XCTest + +/// A shape in front of the camera under one directional light, for tests that read how +/// the engine shades a material: build the scene, hand it a material, read the lit color. +class MaterialShadingTestCase: BaseRenderSetup { + enum Shape { + /// A cube whose front face looks at the camera and fills the middle of the frame. + case cube + /// A sphere in the middle of the frame, about a third of its height across. + case sphere + } + + private var subject: EntityID = .invalid + private var hasItsOwnEnvironment = false + + override func tearDown() async throws { + destroyAllEntities() + subject = .invalid + if hasItsOwnEnvironment { + // The bake of this test's environment went into the textures the engine keeps + // for the default one: drop them, so the next test bakes its own again. + invalidateIBLBakeCache() + hasItsOwnEnvironment = false + } + try await super.tearDown() + } + + override func initializeAssets() { + // Each test builds its own scene. + } + + /// The camera at (0, 0, 5) looking at the origin, the light coming from the right + /// at 45 degrees (pass nil for no light), and the shape at the origin. + func buildScene(_ shape: Shape = .cube, towardsLight: simd_float3? = simd_float3(1, 0, 1)) throws { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 0, 5), target: .zero, up: simd_float3(0, 1, 0)) + + // The renderer starts with a directional light of its own, straight overhead, and + // a new light takes over only when there is none: retire it, so that the scene is + // lit by what the test asks for and by nothing else. + LightingSystem.shared.activeDirectionalLight = nil + if let towardsLight { + let sun = createEntity() + createDirLight(entityId: sun) + rotateTo(entityId: sun, rotation: quaternion_lookAt(eye: simd_normalize(towardsLight), target: .zero, up: simd_float3(0, 1, 0))) + XCTAssertEqual(LightingSystem.shared.activeDirectionalLight, sun) + } + + subject = createEntity() + let meshes: [Mesh] = switch shape { + case .cube: BasicPrimitives.createCube(extent: 3.0) + case .sphere: BasicPrimitives.createSphere(extent: 2.0, segments: [64, 32]) + } + let renderComponent = try XCTUnwrap(scene.assign(to: subject, component: RenderComponent.self)) + renderComponent.mesh = meshes + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/material-shading-subject.untold") + if let local = scene.get(component: LocalTransformComponent.self, for: subject) { + local.boundingBox = Mesh.computeMeshBoundingBox(for: meshes) + } + setVisibleEntities() + } + + /// Lights the scene with an environment that is equally bright all around, and with + /// nothing else. What a surface then shows is the share of that light it gives back: + /// one that gives back all of it comes out as bright as a matte white one. + func lightWithAnEvenEnvironment() throws { + // A Radiance picture of one value: 0.5 is a mantissa of 128 with an exponent of 128. + let width = 64 + let height = 32 + var picture = Data("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y \(height) +X \(width)\n".utf8) + for _ in 0 ..< width * height { + picture.append(contentsOf: [128, 128, 128, 128]) + } + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("even-environment-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: folder) } + try picture.write(to: folder.appendingPathComponent("even.hdr")) + + hasItsOwnEnvironment = true + generateHDR("even.hdr", from: folder) + XCTAssertTrue(iblSuccessful, "the even environment was baked") + applyIBL = true + ambientIntensity = 1.0 + } + + /// Draws the shape with the material and returns the lit frame, in linear values: + /// `width` x `height` pixels of red, green, blue. + func shadeFrame(_ source: RuntimeMaterialSource) throws -> (pixels: [simd_float3], width: Int, height: Int) { + if subject == .invalid { + try buildScene() + } + let material = Material(runtimeMaterial: source, device: renderInfo.device) + let renderComponent = try XCTUnwrap(scene.get(component: RenderComponent.self, for: subject)) + for meshIndex in renderComponent.mesh.indices { + for submeshIndex in renderComponent.mesh[meshIndex].submeshes.indices { + renderComponent.mesh[meshIndex].submeshes[submeshIndex].material = material + } + } + + // Seeded again for every frame: without a culling pass feeding it, the visible + // list of an earlier frame is not kept. + setVisibleEntities() + renderer.draw(in: renderer.metalView) + let rendered = XCTestExpectation(description: "Lit frame") + var frame: (pixels: [simd_float3], width: Int, height: Int) = ([], 0, 0) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + defer { rendered.fulfill() } + guard let texture = textureResources.deferredColorMap, texture.pixelFormat == .rgba16Float else { + XCTFail("Expected an rgba16Float deferred color target") + return + } + var raw = [Float16](repeating: 0, count: texture.width * texture.height * 4) + texture.getBytes( + &raw, bytesPerRow: texture.width * 8, + from: MTLRegionMake2D(0, 0, texture.width, texture.height), mipmapLevel: 0 + ) + var pixels = [simd_float3](repeating: .zero, count: texture.width * texture.height) + for index in pixels.indices { + pixels[index] = simd_float3(Float(raw[index * 4]), Float(raw[index * 4 + 1]), Float(raw[index * 4 + 2])) + } + frame = (pixels, texture.width, texture.height) + } + wait(for: [rendered], timeout: TimeInterval(timeoutFactor)) + return frame + } + + /// The lit color in the middle of the frame: the mean of a 16-pixel window. + func shade(_ source: RuntimeMaterialSource) throws -> simd_float3 { + let frame = try shadeFrame(source) + return Self.meanColor(of: frame, aroundX: frame.width / 2, y: frame.height / 2, window: 16) + } + + static func meanColor(of frame: (pixels: [simd_float3], width: Int, height: Int), aroundX x: Int, y: Int, window: Int) -> simd_float3 { + var sum = simd_float3(repeating: 0) + for row in (y - window / 2) ..< (y + window / 2) { + for column in (x - window / 2) ..< (x + window / 2) { + sum += frame.pixels[row * frame.width + column] + } + } + return sum / Float(window * window) + } + + /// An 8 x 8 grayscale PNG of one value: a single channel of 8 bits, the way an + /// exporter writes a texture whose red, green and blue are the same. + func writeGrayscaleTexture(value: UInt8) throws -> URL { + let size = 8 + var pixels = [UInt8](repeating: value, count: size * size) + let context = try XCTUnwrap(CGContext( + data: &pixels, width: size, height: size, bitsPerComponent: 8, bytesPerRow: size, + space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.none.rawValue + )) + let image = try XCTUnwrap(context.makeImage()) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("texture-gray-\(value)-\(UUID().uuidString).png") + let destination = try XCTUnwrap(CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + XCTAssertTrue(CGImageDestinationFinalize(destination)) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + + /// An 8 x 8 PNG of one color, 8 bits per channel, as an exporter writes a texture. + func writeTexture(red: UInt8, green: UInt8, blue: UInt8) throws -> URL { + let size = 8 + var pixels = [UInt8](repeating: 0, count: size * size * 4) + for index in 0 ..< size * size { + pixels[index * 4] = red + pixels[index * 4 + 1] = green + pixels[index * 4 + 2] = blue + pixels[index * 4 + 3] = 255 + } + let context = try XCTUnwrap(CGContext( + data: &pixels, width: size, height: size, bitsPerComponent: 8, bytesPerRow: size * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + )) + let image = try XCTUnwrap(context.makeImage()) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("texture-\(red)-\(green)-\(blue)-\(UUID().uuidString).png") + let destination = try XCTUnwrap(CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + XCTAssertTrue(CGImageDestinationFinalize(destination)) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } +} diff --git a/Tests/UntoldEngineRenderTests/MeshMetalBuffersTests.swift b/Tests/UntoldEngineRenderTests/MeshMetalBuffersTests.swift new file mode 100644 index 000000000..f1fea5d80 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/MeshMetalBuffersTests.swift @@ -0,0 +1,86 @@ +// +// MeshMetalBuffersTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +import MetalKit +@testable import UntoldEngine +import XCTest + +/// A mesh keeps the Metal buffers of its MetalKit mesh, and a submesh the index data of +/// its MetalKit submesh, so that a draw does not ask MetalKit for them. They must be +/// what MetalKit would have answered. +@MainActor +final class MeshMetalBuffersTests: BaseRenderSetup { + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() {} + + private func assertBuffersAreMetalKits(_ meshes: [Mesh], _ what: String, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertFalse(meshes.isEmpty, what, file: file, line: line) + for mesh in meshes { + let metalKitBuffers = mesh.metalKitMesh.vertexBuffers + XCTAssertEqual(mesh.vertexBuffers.count, metalKitBuffers.count, what, file: file, line: line) + for (kept, metalKit) in zip(mesh.vertexBuffers, metalKitBuffers) { + XCTAssertTrue(kept === metalKit.buffer, "\(what): vertex buffer", file: file, line: line) + } + // The passes bind the six streams of the model's vertex layout: positions, + // normals, texture coordinates, tangents, joint ids and joint weights. + XCTAssertGreaterThanOrEqual(mesh.vertexBuffers.count, 6, what, file: file, line: line) + + XCTAssertFalse(mesh.submeshes.isEmpty, what, file: file, line: line) + for submesh in mesh.submeshes { + let metalKitSubmesh = submesh.metalKitSubmesh + XCTAssertEqual(submesh.primitiveType, metalKitSubmesh.primitiveType, what, file: file, line: line) + XCTAssertEqual(submesh.indexCount, metalKitSubmesh.indexCount, what, file: file, line: line) + XCTAssertEqual(submesh.indexType, metalKitSubmesh.indexType, what, file: file, line: line) + XCTAssertTrue(submesh.indexBuffer === metalKitSubmesh.indexBuffer.buffer, "\(what): index buffer", file: file, line: line) + XCTAssertEqual(submesh.indexBufferOffset, metalKitSubmesh.indexBuffer.offset, what, file: file, line: line) + XCTAssertGreaterThan(submesh.indexCount, 0, what, file: file, line: line) + } + } + } + + func testAPrimitiveKeepsTheBuffersOfItsMetalKitMesh() { + assertBuffersAreMetalKits(BasicPrimitives.createCube(extent: 1), "cube") + assertBuffersAreMetalKits(BasicPrimitives.createSphere(extent: 1), "sphere") + assertBuffersAreMetalKits(BasicPrimitives.createPlane(width: 2, depth: 2), "plane") + } + + func testALoadedModelKeepsTheBuffersOfItsMetalKitMeshes() async throws { + let entity = createEntity() + let loaded = expectation(description: "model") + setEntityMeshAsync(entityId: entity, filename: "ball", withExtension: "untold") { _ in loaded.fulfill() } + await fulfillment(of: [loaded], timeout: 60) + + let render = try XCTUnwrap(scene.get(component: RenderComponent.self, for: entity) ?? firstRenderComponent(under: entity)) + assertBuffersAreMetalKits(render.mesh, "ball.untold") + } + + func testACopyOfAMeshSharesItsBuffers() { + let meshes = BasicPrimitives.createCube(extent: 1) + var copy = meshes + copy[0].localSpace.columns.3.x = 5 + + XCTAssertTrue(copy[0].vertexBuffers[0] === meshes[0].vertexBuffers[0]) + XCTAssertTrue(copy[0].submeshes[0].indexBuffer === meshes[0].submeshes[0].indexBuffer) + } + + private func firstRenderComponent(under entity: EntityID) -> RenderComponent? { + for child in getEntityChildren(parentId: entity) { + if let render = scene.get(component: RenderComponent.self, for: child) ?? firstRenderComponent(under: child) { + return render + } + } + return nil + } +} diff --git a/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift b/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift index b745efe82..ece62f9c3 100644 --- a/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift +++ b/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift @@ -149,6 +149,62 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { XCTAssertEqual(scene.get(component: TileComponent.self, for: tileEntityId)?.lodLevels.first?.entityId, .invalid) } + /// The entities a tile load creates (the stub, and the mesh roots of the tile, its + /// HLOD and its LOD levels) keep the components createEntity() gave them. Registering + /// the transform again asked for a walk over every entity on the next frame, for each + /// tile that streams in. + func testTileEntitiesAreCreatedWithoutAskingForATraversal() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: true, includeLOD: true) + try await loadSceneManifest(at: fixture.manifestURL) + + let tileEntityId = try XCTUnwrap(findEntity(named: fixture.tileID)) + let rootEntityId = try XCTUnwrap(getEntityParent(entityId: tileEntityId), "the stub is parented under the scene root") + let stubTransform = try XCTUnwrap(scene.get(component: LocalTransformComponent.self, for: tileEntityId)) + assertVector(stubTransform.boundingBox.min, equals: simd_float3(-1.0, -1.0, -1.0)) + assertVector(stubTransform.boundingBox.max, equals: simd_float3(1.0, 1.0, 1.0)) + XCTAssertTrue(hasComponent(entityId: tileEntityId, componentType: WorldTransformComponent.self)) + XCTAssertTrue(getEntityChildren(parentId: rootEntityId).contains(tileEntityId)) + + // The scene root's own registration asks for one traversal; let it run. + traverseSceneGraph() + XCTAssertFalse(anyTransformDirty) + + GeometryStreamingSystem.shared.loadTile(entityId: tileEntityId) + let tileParsed = await waitUntil(timeout: 5.0) { + scene.get(component: TileComponent.self, for: tileEntityId)?.state == .parsed + } + XCTAssertTrue(tileParsed) + let meshRootId = try XCTUnwrap(getEntityChildren(parentId: tileEntityId).first, "the tile owns its mesh root") + XCTAssertEqual(getEntityParent(entityId: meshRootId), tileEntityId) + XCTAssertTrue(hasComponent(entityId: meshRootId, componentType: LocalTransformComponent.self)) + XCTAssertTrue(hasComponent(entityId: meshRootId, componentType: WorldTransformComponent.self)) + XCTAssertFalse(anyTransformDirty, "the mesh root of the tile is at the identity transform under its tile") + GeometryStreamingSystem.shared.unloadTile(entityId: tileEntityId) + + GeometryStreamingSystem.shared.loadHLOD(entityId: tileEntityId) + let hlodLoaded = await waitUntil(timeout: 5.0) { + scene.get(component: TileComponent.self, for: tileEntityId)?.hlodState == .loaded + } + XCTAssertTrue(hlodLoaded) + let hlodEntityId = try XCTUnwrap(scene.get(component: TileComponent.self, for: tileEntityId)?.hlodEntityId) + XCTAssertEqual(getEntityParent(entityId: hlodEntityId), tileEntityId) + XCTAssertTrue(hasComponent(entityId: hlodEntityId, componentType: LocalTransformComponent.self)) + XCTAssertTrue(hasComponent(entityId: hlodEntityId, componentType: WorldTransformComponent.self)) + XCTAssertFalse(anyTransformDirty, "the HLOD entity is at the identity transform under its tile") + GeometryStreamingSystem.shared.unloadHLOD(entityId: tileEntityId) + + GeometryStreamingSystem.shared.loadLODLevel(entityId: tileEntityId, levelIndex: 0) + let lodLoaded = await waitUntil(timeout: 5.0) { + scene.get(component: TileComponent.self, for: tileEntityId)?.lodLevels.first?.state == .loaded + } + XCTAssertTrue(lodLoaded) + let lodEntityId = try XCTUnwrap(scene.get(component: TileComponent.self, for: tileEntityId)?.lodLevels.first?.entityId) + XCTAssertEqual(getEntityParent(entityId: lodEntityId), tileEntityId) + XCTAssertTrue(hasComponent(entityId: lodEntityId, componentType: LocalTransformComponent.self)) + XCTAssertTrue(hasComponent(entityId: lodEntityId, componentType: WorldTransformComponent.self)) + XCTAssertFalse(anyTransformDirty, "the LOD entity is at the identity transform under its tile") + } + // MARK: - loadTiledScene(url:) — URL overload parity /// Verifies that passing a local `file://` URL to `loadTiledScene(url:)` @@ -807,6 +863,380 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { tileComp.parseStartTime = 0 } + // MARK: - The root entity of a streamed scene + + /// The root keeps the transform and scene graph components `createEntity()` gave it, so + /// it stays under its parent and keeps the children it already has. + func testStreamSceneRoot_keepsItsParentAndTheChildrenItHad() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let (parent, root, child) = makePlacedRoot() + let localBefore = try XCTUnwrap(scene.get(component: LocalTransformComponent.self, for: root)) + let worldBefore = try XCTUnwrap(scene.get(component: WorldTransformComponent.self, for: root)) + let graphBefore = try XCTUnwrap(scene.get(component: ScenegraphComponent.self, for: root)) + + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + let stub = try XCTUnwrap(findEntity(named: fixture.tileID)) + + XCTAssertTrue(scene.get(component: LocalTransformComponent.self, for: root) === localBefore, "The local transform is the one the root had") + XCTAssertTrue(scene.get(component: WorldTransformComponent.self, for: root) === worldBefore, "The world transform is the one the root had") + XCTAssertTrue(scene.get(component: ScenegraphComponent.self, for: root) === graphBefore, "The scene graph node is the one the root had") + + XCTAssertEqual(getEntityParent(entityId: root), parent, "The root keeps its parent") + XCTAssertEqual(getEntityChildren(parentId: parent), [root], "The parent lists the root once") + XCTAssertEqual(getEntityParent(entityId: child), root, "The earlier child keeps its parent") + XCTAssertEqual(getEntityChildren(parentId: root), [child, stub], "The root lists its earlier child and the tile stub") + XCTAssertEqual(getEntityParent(entityId: stub), root, "The tile stub is parented under the root") + XCTAssertEqual(scene.get(component: ScenegraphComponent.self, for: root)?.level, 1) + XCTAssertEqual(scene.get(component: ScenegraphComponent.self, for: child)?.level, 2) + XCTAssertEqual(scene.get(component: ScenegraphComponent.self, for: stub)?.level, 2) + assertSceneGraphIsConsistent([parent, root, child, stub]) + XCTAssertNotNil(scene.get(component: TiledSceneComponent.self, for: root)) + } + + /// Tile bounds are world-space values that do not follow the root, so its own transform + /// goes back to identity. What its parent adds stays, and the entities under the root + /// are where that puts them without waiting for a scene graph traversal. + func testStreamSceneRoot_isPutBackAtTheIdentityOfItsParentSpace() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let (parent, root, child) = makePlacedRoot() + assertVector(getPosition(entityId: child), equals: simd_float3(11.0, 4.0, 3.0)) + + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + let stub = try XCTUnwrap(findEntity(named: fixture.tileID)) + + assertVector(getLocalPosition(entityId: root), equals: .zero) + assertVector(getScale(entityId: root), equals: .one) + XCTAssertEqual(abs(getRotationQuaternion(entityId: root).real), 1.0, accuracy: 0.0001, "The root is no longer rotated") + assertVector(getAxisRotations(entityId: root), equals: .zero) + + assertVector(getPosition(entityId: root), equals: simd_float3(10.0, 0.0, 0.0)) + assertVector(getPosition(entityId: child), equals: simd_float3(10.0, 1.0, 0.0)) + assertVector(getPosition(entityId: stub), equals: simd_float3(10.0, 0.0, 0.0)) + assertTraversalChangesNothing([parent, root, child, stub]) + } + + func testStreamSceneRoot_destroyTakesTheChildrenItHad() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let (parent, root, child) = makePlacedRoot() + + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + let stub = try XCTUnwrap(findEntity(named: fixture.tileID)) + + destroyEntity(entityId: root) + finalizePendingDestroys() + + XCTAssertFalse(scene.exists(root)) + XCTAssertFalse(scene.exists(stub), "The tile stub goes with the root") + XCTAssertFalse(scene.exists(child), "An entity parented under the root before the scene was attached goes with it") + XCTAssertTrue(scene.exists(parent)) + XCTAssertTrue(getEntityChildren(parentId: parent).isEmpty, "The parent no longer lists the destroyed root") + } + + /// A root that was placed streams from where the manifest puts the tiles, like a fresh one. + func testStreamSceneRoot_placedRootStreamsFromTheAuthoredPlace() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let root = createEntity() + translateTo(entityId: root, position: simd_float3(1000.0, 0.0, 0.0)) + scaleTo(entityId: root, scale: simd_float3(2.0, 2.0, 2.0)) + + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + let stub = try XCTUnwrap(findEntity(named: fixture.tileID)) + XCTAssertEqual(getEntityParent(entityId: stub), root) + assertVector(getPosition(entityId: stub), equals: .zero) + + // The tile spans -1...1 around the origin. A camera 10 m away that looks at it has + // it in range and in view: the streaming tick dispatches it, and nothing else here + // loads it. + let camera = try XCTUnwrap(CameraSystem.shared.activeCamera) + cameraLookAt(entityId: camera, eye: simd_float3(0.0, 0.0, 10.0), target: .zero, up: simd_float3(0.0, 1.0, 0.0)) + GeometryStreamingSystem.shared.update(cameraPosition: simd_float3(0.0, 0.0, 10.0), deltaTime: 0.016) + + let tileParsed = await waitUntil(timeout: 5.0) { + scene.get(component: TileComponent.self, for: stub)?.state == .parsed + } + XCTAssertTrue(tileParsed, "The streaming tick should dispatch the tile under a root that was placed") + + let meshRoot = try XCTUnwrap(getEntityChildren(parentId: stub).first) + XCTAssertFalse(GeometryStreamingSystem.shared.collectRenderDescendantIds(meshRoot).isEmpty) + assertSceneGraphIsConsistent([root, stub, meshRoot]) + } + + func testStreamSceneRoot_freshRootIsLeftAsItIs() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let rootName = "Root \(UUID().uuidString)" + let root = createEntity() + setEntityName(entityId: root, name: rootName) + let localBefore = try XCTUnwrap(scene.get(component: LocalTransformComponent.self, for: root)) + let worldBefore = try XCTUnwrap(scene.get(component: WorldTransformComponent.self, for: root)) + let graphBefore = try XCTUnwrap(scene.get(component: ScenegraphComponent.self, for: root)) + + let warnings = await warningsLogged(mentioning: rootName) { + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + } + let stub = try XCTUnwrap(findEntity(named: fixture.tileID)) + + XCTAssertTrue(scene.get(component: LocalTransformComponent.self, for: root) === localBefore) + XCTAssertTrue(scene.get(component: WorldTransformComponent.self, for: root) === worldBefore) + XCTAssertTrue(scene.get(component: ScenegraphComponent.self, for: root) === graphBefore) + XCTAssertEqual(getEntityChildren(parentId: root), [stub]) + XCTAssertNil(getEntityParent(entityId: root)) + assertSceneGraphIsConsistent([root, stub]) + XCTAssertEqual(warnings, [], "A root from createEntity() is at identity: there is nothing to report") + } + + /// A parent at identity, such as a group that only organizes the hierarchy, does not + /// move the tiles: the root stays in it and nothing is reported. + func testStreamSceneRoot_inAGroupAtIdentityStaysInTheGroup() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let rootName = "Root \(UUID().uuidString)" + let group = createEntity() + let root = createEntity() + setEntityName(entityId: root, name: rootName) + setParent(childId: root, parentId: group) + + let warnings = await warningsLogged(mentioning: rootName) { + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + } + let stub = try XCTUnwrap(findEntity(named: fixture.tileID)) + + XCTAssertEqual(getEntityParent(entityId: root), group) + XCTAssertEqual(getEntityChildren(parentId: group), [root]) + assertVector(getPosition(entityId: stub), equals: .zero) + assertSceneGraphIsConsistent([group, root, stub]) + XCTAssertEqual(warnings, []) + } + + /// An entity that does not come from `createEntity()` may have no transform or scene + /// graph node; the root gets them then, as before. + func testStreamSceneRoot_withoutTransformComponentsGetsThem() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let root = createEntity() + removeEntityTransforms(entityId: root) + scene.remove(component: ScenegraphComponent.self, from: root) + + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + let stub = try XCTUnwrap(findEntity(named: fixture.tileID)) + + XCTAssertTrue(hasComponent(entityId: root, componentType: LocalTransformComponent.self)) + XCTAssertTrue(hasComponent(entityId: root, componentType: WorldTransformComponent.self)) + XCTAssertTrue(hasComponent(entityId: root, componentType: ScenegraphComponent.self)) + XCTAssertEqual(getEntityChildren(parentId: root), [stub]) + assertSceneGraphIsConsistent([root, stub]) + } + + func testStreamSceneRoot_warnsWhenItsTransformIsDiscarded() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let rootName = "Root \(UUID().uuidString)" + let root = createEntity() + setEntityName(entityId: root, name: rootName) + translateTo(entityId: root, position: simd_float3(1.0, 2.0, 3.0)) + + let warnings = await warningsLogged(mentioning: rootName) { + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + } + + XCTAssertEqual(warnings.count, 1, "\(warnings)") + XCTAssertTrue(warnings.first?.contains("reset to identity") == true, "\(warnings)") + assertVector(getPosition(entityId: root), equals: .zero) + } + + func testStreamSceneRoot_warnsWhenAnAncestorKeepsItAwayFromIdentity() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + let rootName = "Root \(UUID().uuidString)" + let grandparent = createEntity() + let parent = createEntity() + let root = createEntity() + setEntityName(entityId: root, name: rootName) + setParent(childId: parent, parentId: grandparent) + setParent(childId: root, parentId: parent) + translateTo(entityId: grandparent, position: simd_float3(10.0, 0.0, 0.0)) + + let warnings = await warningsLogged(mentioning: rootName) { + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + } + + XCTAssertEqual(warnings.count, 1, "\(warnings)") + XCTAssertTrue(warnings.first?.contains("because of its ancestors") == true, "\(warnings)") + // The hierarchy is the caller's: the root stays where its ancestors put it. + XCTAssertEqual(getEntityParent(entityId: root), parent) + assertVector(getPosition(entityId: root), equals: simd_float3(10.0, 0.0, 0.0)) + } + + /// A scene saved with a stream root that was moved and parented after it was loaded, + /// which the editor allows. The deserializer attaches the stream scene to an entity it + /// then places and parents from the file. + func testStreamSceneRoot_savedSceneLoadsWithAConsistentSceneGraph() async throws { + let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) + // The serializer stores the manifest of a project by its path inside the project. + let previousAssetBasePath = assetBasePath + assetBasePath = fixture.manifestURL.deletingLastPathComponent() + defer { assetBasePath = previousAssetBasePath } + + let parent = createEntity() + setEntityName(entityId: parent, name: "Saved Parent") + let root = createEntity() + setEntityName(entityId: root, name: "Saved Root") + let didSucceed = await attachStreamScene(to: root, url: fixture.manifestURL) + XCTAssertTrue(didSucceed) + + setParent(childId: root, parentId: parent) + translateTo(entityId: root, position: simd_float3(1.0, 2.0, 3.0)) + let child = createEntity() + setEntityName(entityId: child, name: "Saved Child") + setParent(childId: child, parentId: root) + translateTo(entityId: child, position: simd_float3(0.0, 1.0, 0.0)) + + let sceneData = serializeScene() + XCTAssertEqual(sceneData.entities.first(where: { $0.name == "Saved Root" })?.asset?.kind, .streamModel) + + destroyAllEntities() + finalizePendingDestroys() + GeometryStreamingSystem.shared.reset() + GeometryStreamingSystem.shared.enabled = true + + let sceneLoaded = expectation(description: "Scene deserialized") + deserializeScene(sceneData: sceneData, completion: { sceneLoaded.fulfill() }) + await fulfillment(of: [sceneLoaded], timeout: 10.0) + + let loadedParent = try XCTUnwrap(findEntity(named: "Saved Parent")) + let loadedRoot = try XCTUnwrap(findEntity(named: "Saved Root")) + let loadedChild = try XCTUnwrap(findEntity(named: "Saved Child")) + let loadedStub = try XCTUnwrap(findEntity(named: fixture.tileID)) + + XCTAssertEqual(getEntityParent(entityId: loadedRoot), loadedParent) + XCTAssertEqual(getEntityChildren(parentId: loadedParent), [loadedRoot]) + XCTAssertEqual(getEntityParent(entityId: loadedChild), loadedRoot) + XCTAssertEqual(Set(getEntityChildren(parentId: loadedRoot)), [loadedChild, loadedStub]) + XCTAssertEqual(getEntityParent(entityId: loadedStub), loadedRoot) + assertSceneGraphIsConsistent([loadedParent, loadedRoot, loadedChild, loadedStub]) + + // The deserializer holds the world mutation gate until it has restored the saved + // transform and parent of the root; the root is prepared under the same gate, so + // the saved transform is always the one that is reset. + assertVector(getLocalPosition(entityId: loadedRoot), equals: .zero) + assertVector(getPosition(entityId: loadedChild), equals: simd_float3(0.0, 1.0, 0.0)) + assertVector(getPosition(entityId: loadedStub), equals: .zero) + assertTraversalChangesNothing([loadedParent, loadedRoot, loadedChild, loadedStub]) + } + + /// A root at (1, 2, 3), turned and at twice its size, under a parent at (10, 0, 0), with + /// a child of its own one metre above it. + private func makePlacedRoot() -> (parent: EntityID, root: EntityID, child: EntityID) { + let parent = createEntity() + let root = createEntity() + let child = createEntity() + translateTo(entityId: parent, position: simd_float3(10.0, 0.0, 0.0)) + setParent(childId: root, parentId: parent) + setParent(childId: child, parentId: root) + translateTo(entityId: root, position: simd_float3(1.0, 2.0, 3.0)) + applyAxisRotations(entityId: root, axis: simd_float3(0.0, 90.0, 0.0)) + scaleTo(entityId: root, scale: simd_float3(2.0, 2.0, 2.0)) + translateTo(entityId: child, position: simd_float3(0.0, 1.0, 0.0)) + return (parent, root, child) + } + + private func attachStreamScene(to rootEntityId: EntityID, url: URL) async -> Bool { + await withCheckedContinuation { continuation in + setEntityStreamScene(entityId: rootEntityId, url: url) { @Sendable success in + continuation.resume(returning: success) + } + } + } + + /// Every link of the scene graph has to be stated by both of its ends: an entity is in + /// the list of the parent it names, once, one level below it, and the children it lists + /// name it as their parent. + private func assertSceneGraphIsConsistent( + _ entities: [EntityID], + file: StaticString = #filePath, + line: UInt = #line + ) { + for entityId in entities { + guard let graph = scene.get(component: ScenegraphComponent.self, for: entityId) else { + XCTFail("Entity \(entityId) has no scene graph node", file: file, line: line) + continue + } + + if graph.parent == .invalid { + XCTAssertEqual(graph.level, 0, "Entity \(entityId) has no parent", file: file, line: line) + } else if let parentGraph = scene.get(component: ScenegraphComponent.self, for: graph.parent) { + XCTAssertEqual( + parentGraph.children.filter { $0 == entityId }.count, 1, + "Entity \(entityId) names a parent that should list it once", file: file, line: line + ) + XCTAssertEqual(graph.level, parentGraph.level + 1, "Entity \(entityId) is one level below its parent", file: file, line: line) + } else { + XCTFail("Entity \(entityId) names a parent that is not in the scene graph", file: file, line: line) + } + + for childId in graph.children { + XCTAssertEqual( + scene.get(component: ScenegraphComponent.self, for: childId)?.parent, entityId, + "Entity \(entityId) lists a child that should name it as its parent", file: file, line: line + ) + } + } + } + + /// The transform API keeps world matrices current, so they have to be the ones a scene + /// graph traversal computes already: run one over the entities and check that it moves + /// nothing. + private func assertTraversalChangesNothing( + _ entities: [EntityID], + file: StaticString = #filePath, + line: UInt = #line + ) { + let worldBefore = entities.map { scene.get(component: WorldTransformComponent.self, for: $0)?.space } + + for entityId in entities { + scene.get(component: LocalTransformComponent.self, for: entityId)?.transformDirty = true + } + anyTransformDirty = true + traverseSceneGraph() + + for (entityId, before) in zip(entities, worldBefore) { + guard let before, let after = scene.get(component: WorldTransformComponent.self, for: entityId)?.space else { + XCTFail("Entity \(entityId) has no world transform", file: file, line: line) + continue + } + for column in 0 ..< 4 { + XCTAssertLessThan( + simd_length(after[column] - before[column]), 0.0001, + "The traversal moved column \(column) of entity \(entityId)", file: file, line: line + ) + } + } + } + + /// Runs `body` and returns the warnings logged during it that mention `text`. + private func warningsLogged(mentioning text: String, during body: () async -> Void) async -> [String] { + let recorder = LogRecorder() + let previousLogLevel = Logger.logLevel + Logger.logLevel = .debug + defer { Logger.logLevel = previousLogLevel } + Logger.addSink(recorder) + + await body() + + // Sinks are served in order on a queue of their own: once this marker has arrived, + // so has everything logged before it. + let marker = "End of recording \(UUID().uuidString)" + Logger.log(message: marker) + let markerArrived = await waitUntil(timeout: 5.0) { recorder.hasLogged(marker) } + XCTAssertTrue(markerArrived, "The log sink should have been served") + + return recorder.warnings().filter { $0.contains(text) } + } + private func loadSceneManifest(at manifestURL: URL) async throws { let expectation = XCTestExpectation(description: "Manifest loaded") let manifestStem = manifestURL.deletingPathExtension().path @@ -848,6 +1278,30 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { } } +/// Keeps what the engine logs. A sink is called on the logger's queue. +private final class LogRecorder: LoggerSink, @unchecked Sendable { + private let lock = NSLock() + private var events: [LogEvent] = [] + + func didLog(_ event: LogEvent) { + lock.lock() + events.append(event) + lock.unlock() + } + + func hasLogged(_ message: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return events.contains { $0.message == message } + } + + func warnings() -> [String] { + lock.lock() + defer { lock.unlock() } + return events.filter { $0.level == .warning }.map(\.message) + } +} + private struct UntoldTileSceneFixture { let manifestURL: URL let tileID: String diff --git a/Tests/UntoldEngineRenderTests/NormalMapShadingTests.swift b/Tests/UntoldEngineRenderTests/NormalMapShadingTests.swift new file mode 100644 index 000000000..1459949f9 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/NormalMapShadingTests.swift @@ -0,0 +1,83 @@ +// +// NormalMapShadingTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// How a tangent-space normal map changes the shading of a lit surface: a gray, rough +/// cube face that looks at the camera, lit by one light that comes from the right at 45 +/// degrees and by nothing else, so the face is as bright as it is turned to the light. +final class NormalMapShadingTests: MaterialShadingTestCase { + /// The face leaning 45 degrees towards the light, and 45 degrees away from it. + private static let towardsTheLight: (red: UInt8, green: UInt8, blue: UInt8) = (218, 128, 218) + private static let awayFromTheLight: (red: UInt8, green: UInt8, blue: UInt8) = (37, 128, 218) + + private func buildLitFace() throws { + try buildScene(.cube, towardsLight: simd_float3(1, 0, 1)) + ambientIntensity = 0.0 + } + + private func litColor(normalMap: URL?, normalScale: Float = 1.0) throws -> simd_float3 { + try shade(RuntimeMaterialSource( + baseColorFactor: simd_float4(0.8, 0.8, 0.8, 1), + normalScale: normalScale, + metallicFactor: 0.0, + roughnessFactor: 1.0, + normalTexture: normalMap.map { RuntimeTextureReference(name: $0.lastPathComponent, sourceURL: $0) } + )) + } + + private func normalMap(_ color: (red: UInt8, green: UInt8, blue: UInt8)) throws -> URL { + try writeTexture(red: color.red, green: color.green, blue: color.blue) + } + + /// A flat normal map says "the surface as it is": (128, 128, 255) decodes to (0, 0, 1). + /// It used to come out about 22 degrees off, because the stored color was normalized + /// before the remap to [-1, 1]. + func testANeutralNormalMapShadesLikeNoNormalMap() throws { + try buildLitFace() + let plain = try litColor(normalMap: nil) + let neutral = try litColor(normalMap: writeTexture(red: 128, green: 128, blue: 255)) + XCTAssertGreaterThan(plain.x, 0.01, "the face is lit") + for channel in 0 ..< 3 { + XCTAssertEqual(neutral[channel], plain[channel], accuracy: plain[channel] * 0.03) + } + } + + /// A map that leans the normal 45 degrees along the tangent turns the face to look + /// straight at the light, or edge on to it. + func testALeaningNormalTurnsTheFaceToTheLightOrAwayFromIt() throws { + try buildLitFace() + let plain = try litColor(normalMap: nil).x + let towards = try litColor(normalMap: normalMap(Self.towardsTheLight)).x + let away = try litColor(normalMap: normalMap(Self.awayFromTheLight)).x + + // From 45 degrees off the light to facing it: 1 / cos 45. + XCTAssertEqual(towards, plain * Float(2.0).squareRoot(), accuracy: plain * 0.05) + XCTAssertLessThan(away, plain * 0.05) + } + + /// The material's normal strength: none of the map at 0, all of it at 1, part of it + /// in between. + func testTheNormalStrengthScalesTheMap() throws { + try buildLitFace() + let plain = try litColor(normalMap: nil).x + let map = try normalMap(Self.awayFromTheLight) + let full = try litColor(normalMap: map, normalScale: 1.0).x + let half = try litColor(normalMap: map, normalScale: 0.5).x + let none = try litColor(normalMap: map, normalScale: 0.0).x + + XCTAssertEqual(none, plain, accuracy: plain * 0.03) + XCTAssertLessThan(full, plain * 0.05, "the whole map turns the face edge on to the light") + // Half the strength leans the normal 22.5 degrees: cos 67.5 / cos 45 of the light. + XCTAssertEqual(half, plain * 0.541, accuracy: plain * 0.04) + } +} diff --git a/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift b/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift index b6f08abe2..1902b9ff8 100644 --- a/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift +++ b/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift @@ -1,3 +1,13 @@ +// +// RemoteStreamFlyThroughTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // RemoteStreamFlyThroughTests.swift // UntoldEngine diff --git a/Tests/UntoldEngineRenderTests/RenderExtensionAPIContractTest.swift b/Tests/UntoldEngineRenderTests/RenderExtensionAPIContractTest.swift index 550d13bd9..799607152 100644 --- a/Tests/UntoldEngineRenderTests/RenderExtensionAPIContractTest.swift +++ b/Tests/UntoldEngineRenderTests/RenderExtensionAPIContractTest.swift @@ -1,3 +1,13 @@ +// +// RenderExtensionAPIContractTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Metal import simd @testable import UntoldEngine diff --git a/Tests/UntoldEngineRenderTests/RenderExtensionSceneTargetAccessTest.swift b/Tests/UntoldEngineRenderTests/RenderExtensionSceneTargetAccessTest.swift index 839436074..9e569b4c3 100644 --- a/Tests/UntoldEngineRenderTests/RenderExtensionSceneTargetAccessTest.swift +++ b/Tests/UntoldEngineRenderTests/RenderExtensionSceneTargetAccessTest.swift @@ -1,3 +1,13 @@ +// +// RenderExtensionSceneTargetAccessTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Metal import simd @testable import UntoldEngine diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BRDFIBLReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BRDFIBLReference.png index c2d2f5190..1ebb3d29c 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BRDFIBLReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BRDFIBLReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png index 320e9a1a5..7b47cf965 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png index e60b39996..b45ece77f 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png index 09c845a16..bd25c0a16 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png index 6122281a2..749b2bb2a 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DisplacementMapReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DisplacementMapReference.png index db5a74bf3..04229620e 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DisplacementMapReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DisplacementMapReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png index d56eb9851..1c1fc379d 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png index db5a74bf3..04229620e 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/MSAAReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/MSAAReference.png index 83ded206e..3c04aac0f 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/MSAAReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/MSAAReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png index ae68e9320..1c78859ad 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png index db5a74bf3..04229620e 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png index db5a74bf3..04229620e 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png differ diff --git a/Tests/UntoldEngineRenderTests/SceneBuilderNodesTest.swift b/Tests/UntoldEngineRenderTests/SceneBuilderNodesTest.swift index 61adf6133..725ce970f 100644 --- a/Tests/UntoldEngineRenderTests/SceneBuilderNodesTest.swift +++ b/Tests/UntoldEngineRenderTests/SceneBuilderNodesTest.swift @@ -181,4 +181,19 @@ final class SceneBuilderNodesTest: BaseRenderSetup { XCTAssertEqual(getLightIntensity(entityId: entity), 7, "Wrapping an existing point light must not recreate the component") } + + func testLightNodeKeepsThePlaceOfAnExistingEntity() { + let parent = createEntity() + let entity = createEntity() + setParent(childId: entity, parentId: parent) + translateTo(entityId: entity, position: simd_float3(1.0, 2.0, 3.0)) + + let light = SpotLightNode(entityID: entity) + + XCTAssertEqual(light.entityID, entity) + XCTAssertEqual(getLocalPosition(entityId: entity), simd_float3(1.0, 2.0, 3.0), + "Declaring a light on an entity that is already placed must not move it") + XCTAssertEqual(getEntityParent(entityId: entity), parent) + XCTAssertEqual(getEntityChildren(parentId: parent), [entity]) + } } diff --git a/Tests/UntoldEngineRenderTests/SmallObjectCullingRenderTests.swift b/Tests/UntoldEngineRenderTests/SmallObjectCullingRenderTests.swift new file mode 100644 index 000000000..d5b999c61 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/SmallObjectCullingRenderTests.swift @@ -0,0 +1,108 @@ +// +// SmallObjectCullingRenderTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +final class SmallObjectCullingRenderTests: BaseRenderSetup { + private var savedPixels: Float = 1 + + override func setUp() async throws { + try await super.setUp() + savedPixels = SmallObjectCulling.minimumPixels + } + + override func tearDown() async throws { + SmallObjectCulling.minimumPixels = savedPixels + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: .zero, target: simd_float3(0, 0, -1), up: simd_float3(0, 1, 0)) + } + + private func makeCube(size: Float, at position: simd_float3) -> EntityID { + let entity = createEntity() + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "Cube") + scaleTo(entityId: entity, scale: simd_float3(repeating: size)) + translateTo(entityId: entity, position: position) + return entity + } + + /// Draws enough frames for a culling result to come back and be picked up. + private func drawFrames() { + for _ in 0 ..< 6 { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + } + + func testAnObjectUnderAPixelIsNotInTheVisibleSet() { + // The view is 1,080 pixels high with a 65° field of view: at 400 units a pixel + // is about 0.47 units. The near cube is off to the side, so that it hides neither + // of the far ones. + let near = makeCube(size: 1, at: simd_float3(-3, 0, -5)) + let farLarge = makeCube(size: 20, at: simd_float3(30, 0, -400)) + let farSmall = makeCube(size: 0.1, at: simd_float3(-10, 0, -400)) + + setRendering(.smallObjectCulling(pixels: 1)) + drawFrames() + + XCTAssertTrue(visibleEntityIds.contains(near)) + XCTAssertTrue(visibleEntityIds.contains(farLarge)) + XCTAssertFalse(visibleEntityIds.contains(farSmall), "0.37 pixels tall") + + setRendering(.smallObjectCulling(pixels: 0)) + drawFrames() + + XCTAssertTrue(visibleEntityIds.contains(farSmall), "everything in the frustum is drawn when the limit is zero") + } + + func testTheLimitDecidesWhatCountsAsSmall() { + // 4 units wide at 400: the sphere around it is about 14.7 pixels tall. + let cube = makeCube(size: 4, at: simd_float3(0, 0, -400)) + + setRendering(.smallObjectCulling(pixels: 8)) + drawFrames() + XCTAssertTrue(visibleEntityIds.contains(cube)) + + setRendering(.smallObjectCulling(pixels: 20)) + drawFrames() + XCTAssertFalse(visibleEntityIds.contains(cube)) + XCTAssertTrue(visibleEntityIds.isEmpty, "with nothing left to test, the frame stops drawing the set of the frame before") + } + + func testAnObjectTooSmallToDrawCastsNoShadow() { + let sun = createEntity() + createDirLight(entityId: sun) + let ground = makeCube(size: 1, at: simd_float3(0, -2, -20)) + scaleTo(entityId: ground, scale: simd_float3(60, 0.2, 60)) + // 5 mm across, 20 units away: a third of a pixel. + let speck = makeCube(size: 0.005, at: simd_float3(0, 1, -20)) + RenderPasses.invalidateShadowEntityCache() + + setRendering(.smallObjectCulling(pixels: 0)) + drawFrames() + let castersWithEverything = RenderPasses.lastShadowCasterCount + + setRendering(.smallObjectCulling(pixels: 1)) + drawFrames() + let castersWithoutTheSpeck = RenderPasses.lastShadowCasterCount + + XCTAssertGreaterThan(castersWithEverything, 0, "the ground casts into the cascade") + XCTAssertEqual(castersWithoutTheSpeck, castersWithEverything - 1) + XCTAssertTrue(scene.exists(speck)) + } +} diff --git a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift index 8c814a7e2..9ec2b170c 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift @@ -1736,6 +1736,40 @@ final class StaticBatchingTest: BaseRenderSetup { return entity } + func testNormalStrengthDifferencePreventsBatching() { + /// A batch draws all of its geometry with one material. Two materials that share + /// a normal map and differ in how much of it they take are two materials. + func makeMaterial(normalScale: Float) -> Material { + Material( + runtimeMaterial: RuntimeMaterialSource( + baseColorFactor: simd_float4(1, 1, 1, 1), + normalScale: normalScale, + metallicFactor: 0.0, + roughnessFactor: 1.0 + ), + device: renderInfo.device + ) + } + + let entityFullA = addCubeEntity(material: makeMaterial(normalScale: 1.0), position: simd_float3(0, 6, 0)) + let entityFullB = addCubeEntity(material: makeMaterial(normalScale: 1.0), position: simd_float3(2, 6, 0)) + let entityFaintA = addCubeEntity(material: makeMaterial(normalScale: 0.1), position: simd_float3(4, 6, 0)) + let entityFaintB = addCubeEntity(material: makeMaterial(normalScale: 0.1), position: simd_float3(6, 6, 0)) + + generateBatches() + + let batchFullA = BatchingSystem.shared.getBatchInfo(for: entityFullA)?.batchId + let batchFullB = BatchingSystem.shared.getBatchInfo(for: entityFullB)?.batchId + let batchFaintA = BatchingSystem.shared.getBatchInfo(for: entityFaintA)?.batchId + let batchFaintB = BatchingSystem.shared.getBatchInfo(for: entityFaintB)?.batchId + + XCTAssertNotNil(batchFullA, "āŒ Full-strength entity should be batched") + XCTAssertNotNil(batchFaintA, "āŒ Faint-strength entity should be batched") + XCTAssertEqual(batchFullA, batchFullB, "āŒ Entities with the same normal strength should share a batch") + XCTAssertEqual(batchFaintA, batchFaintB, "āŒ Entities with the same normal strength should share a batch") + XCTAssertNotEqual(batchFullA, batchFaintA, "āŒ Materials differing only in normal strength must not share a batch") + } + func testHeightMaterialDifferencesPreventBatching() { /// Regression test: getMaterialHash() previously had no awareness of height/POM /// parameters at all, so two materials differing only in heightScale (or @@ -1813,6 +1847,41 @@ final class StaticBatchingTest: BaseRenderSetup { XCTAssertNotEqual(batchOnA, batchOffA, "āŒ heightEnabled true/false must not share a batch") } + func testEmissiveDifferencePreventsBatching() { + /// A batch draws all of its geometry with one material. The emissive color was + /// not part of what tells two materials apart, so two lamps alike in all but + /// their glow could share a batch, and both took the glow of the first. + func makeMaterial(emissive: simd_float3) -> Material { + Material( + runtimeMaterial: RuntimeMaterialSource( + baseColorFactor: simd_float4(1, 1, 1, 1), + emissiveFactor: emissive, + metallicFactor: 0.0, + roughnessFactor: 1.0 + ), + device: renderInfo.device + ) + } + + let entityRedA = addCubeEntity(material: makeMaterial(emissive: simd_float3(1, 0, 0)), position: simd_float3(0, 4, 0)) + let entityRedB = addCubeEntity(material: makeMaterial(emissive: simd_float3(1, 0, 0)), position: simd_float3(2, 4, 0)) + let entityBlueA = addCubeEntity(material: makeMaterial(emissive: simd_float3(0, 0, 1)), position: simd_float3(4, 4, 0)) + let entityBlueB = addCubeEntity(material: makeMaterial(emissive: simd_float3(0, 0, 1)), position: simd_float3(6, 4, 0)) + + generateBatches() + + let batchRedA = BatchingSystem.shared.getBatchInfo(for: entityRedA)?.batchId + let batchRedB = BatchingSystem.shared.getBatchInfo(for: entityRedB)?.batchId + let batchBlueA = BatchingSystem.shared.getBatchInfo(for: entityBlueA)?.batchId + let batchBlueB = BatchingSystem.shared.getBatchInfo(for: entityBlueB)?.batchId + + XCTAssertNotNil(batchRedA, "āŒ Red-glow entity should be batched") + XCTAssertNotNil(batchBlueA, "āŒ Blue-glow entity should be batched") + XCTAssertEqual(batchRedA, batchRedB, "āŒ Entities with the same emissive color should share a batch") + XCTAssertEqual(batchBlueA, batchBlueB, "āŒ Entities with the same emissive color should share a batch") + XCTAssertNotEqual(batchRedA, batchBlueA, "āŒ Materials differing only in emissive color must not share a batch") + } + func testRuntimeOpacityChangeRemovesBatchedEntity() { // Verify that calling updateMaterialOpacity on a batched entity correctly // removes it from the batch so the transparency pass can render it. diff --git a/Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift b/Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift new file mode 100644 index 000000000..bde2a6048 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift @@ -0,0 +1,359 @@ +// +// UntoldPackLODRenderTests.swift +// UntoldEngine +// +// Loading a `.untoldpack` whose manifest carries LOD chains: the levels each placement +// gets, where they switch, what the placements share and what stays their own. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd +@testable import UntoldEngine +@testable import UntoldEngineMeshCook +import XCTest + +final class UntoldPackLODRenderTests: BaseRenderSetup { + private var tempRoot: URL! + + override func setUp() async throws { + try await super.setUp() + LoadingSystem.shared.resourceURLFn = getResourceURL + LODSystem.shared.reset() + tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("UntoldPackLODRenderTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + LoadingSystem.shared.resourceURLFn = getResourceURL + LODSystem.shared.reset() + destroyAllEntities() + if let tempRoot { + try? FileManager.default.removeItem(at: tempRoot) + } + try await super.tearDown() + } + + override func initializeAssets() {} + + private struct Placement { + var name: String + /// A bundled test model: "ball" (one mesh, 180 triangles) or "stadium" (seven). + var asset: String + var path: String + var position = SIMD3(0, 0, 0) + var scale: Float = 1 + } + + /// Copies the bundled models to their paths under tempRoot, writes a manifest that + /// places them, and cooks the LOD chains of the pack. The bundled models are small, + /// so the cook is asked to chain models of any size. + private func writePack(_ placements: [Placement], cookChains: Bool = true) throws -> URL { + var models: [[String: Any]] = [] + for placement in placements { + let source = try XCTUnwrap( + LoadingSystem.shared.resourceURL(forResource: placement.asset, withExtension: "untold"), + "bundled \(placement.asset).untold not found" + ) + let destination = tempRoot.appendingPathComponent(placement.path) + if !FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.copyItem(at: source, to: destination) + } + let scale = Double(placement.scale) + models.append([ + "displayName": placement.name, + "path": placement.path, + "transform": [ + [scale, 0, 0, Double(placement.position.x)], + [0, scale, 0, Double(placement.position.y)], + [0, 0, scale, Double(placement.position.z)], + [0, 0, 0, 1], + ], + ]) + } + let manifest: [String: Any] = ["formatVersion": 1, "sourceAsset": "Site.blend", "models": models] + let packURL = tempRoot.appendingPathComponent("Site").appendingPathExtension("untoldpack") + try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted]).write(to: packURL) + if cookChains { + let report = try UntoldMeshLODCooker.cookChains(forPackAt: packURL, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertTrue(report.failures.isEmpty) + } + return packURL + } + + /// Loads a pack and returns its root and its placements by display name. + private func loadPack(_ packURL: URL, expectSuccess: Bool = true) async throws -> (root: EntityID, children: [String: EntityID]) { + let rootId = createEntity() + let expectation = expectation(description: "pack load completes") + setEntityMeshAsync(entityId: rootId, filename: packURL.deletingPathExtension().path, withExtension: "untoldpack") { success in + XCTAssertEqual(success, expectSuccess) + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 20.0) + let scenegraph = try XCTUnwrap(scene.get(component: ScenegraphComponent.self, for: rootId)) + return (rootId, Dictionary(uniqueKeysWithValues: scenegraph.children.map { (getEntityName(entityId: $0), $0) })) + } + + private func lod(_ entityId: EntityID) throws -> LODComponent { + try XCTUnwrap(scene.get(component: LODComponent.self, for: entityId), "entity \(entityId) has no LODComponent") + } + + private func identities(_ meshes: [Mesh]) -> [ObjectIdentifier] { + meshes.map { ObjectIdentifier($0.metalKitMesh) } + } + + private func drawnMeshes(_ entityId: EntityID) throws -> [ObjectIdentifier] { + try identities(XCTUnwrap(scene.get(component: RenderComponent.self, for: entityId)).mesh) + } + + /// Puts the camera `distance` away from `entityId` and lets the LOD system choose. + private func selectLOD(for entityId: EntityID, cameraAt distance: Float, camera: EntityID) { + traverseSceneGraph() + let center = getPosition(entityId: entityId) + cameraLookAt(entityId: camera, eye: center + SIMD3(0, 0, distance), target: center, up: SIMD3(0, 1, 0)) + LODSystem.shared.reset() + LODSystem.shared.update(deltaTime: 1.0 / 60.0) + } + + private func makeCamera() -> EntityID { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + return camera + } + + // MARK: - Levels + + func testAPlacementGetsItsModelAndTheLevelsOfItsChain() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")]) + let chain = try XCTUnwrap(loadUntoldPack(url: packURL)?.lodChains?["Ball/Ball.untold"]) + XCTAssertEqual(chain.count, 3) + + let (_, children) = try await loadPack(packURL) + + let ball = try XCTUnwrap(children["Ball"]) + let component = try lod(ball) + XCTAssertEqual(component.lodLevels.count, chain.count + 1) + XCTAssertEqual(component.currentLOD, 0) + XCTAssertTrue(component.levelsShareMaterials) + XCTAssertEqual(identities(component.lodLevels[0].mesh), try drawnMeshes(ball), "level 0 is the model itself") + XCTAssertEqual(component.lodLevels[0].url?.lastPathComponent, "Ball.untold") + for (index, entry) in chain.enumerated() { + let level = component.lodLevels[index + 1] + XCTAssertEqual(level.url?.standardizedFileURL, packURL.deletingLastPathComponent().appendingPathComponent(entry.path).standardizedFileURL) + XCTAssertEqual(level.screenPercentage, entry.screenSize) + XCTAssertEqual(level.residencyState, .resident) + XCTAssertEqual(level.mesh.count, 1) + XCTAssertLessThan( + level.mesh[0].metalKitMesh.submeshes[0].indexCount, + component.lodLevels[index].mesh[0].metalKitMesh.submeshes[0].indexCount, + "each level has fewer triangles than the one before" + ) + } + } + + func testEachLevelEndsWhereTheNextIsDetailedEnough() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")]) + let chain = try XCTUnwrap(loadUntoldPack(url: packURL)?.lodChains?["Ball/Ball.untold"]) + + let (_, children) = try await loadPack(packURL) + + let levels = try lod(XCTUnwrap(children["Ball"])).lodLevels + let model = try NativeFormatLoader().loadAssetSync(from: tempRoot.appendingPathComponent("Ball/Ball.untold")) + let radius = boundingRadius(of: model.worldBounds) + XCTAssertEqual(radius, Float(3).squareRoot() / 2, accuracy: 0.01, "the ball fills a cube of side 1") + for (index, entry) in chain.enumerated() { + let expected = lodSwitchDistance(radius: radius, screenSize: entry.screenSize, fovYDegrees: fov) + XCTAssertEqual(levels[index].maxDistance, expected, accuracy: expected * 1e-3, "level \(index) hands over to level \(index + 1)") + } + XCTAssertEqual(levels.map(\.maxDistance), levels.map(\.maxDistance).sorted()) + XCTAssertEqual(levels.last?.maxDistance, .greatestFiniteMagnitude, "the last level has no end") + } + + func testTheSwitchDistanceIsWhereTheModelCoversTheScreenSize() { + // A sphere of radius 1 fills the viewport height at the distance where the half + // height of the view is 1. + let fullHeight = lodSwitchDistance(radius: 1, screenSize: 1, fovYDegrees: 90) + XCTAssertEqual(fullHeight, 1, accuracy: 1e-5) + XCTAssertEqual(lodSwitchDistance(radius: 1, screenSize: 0.25, fovYDegrees: 90), 4, accuracy: 1e-4) + XCTAssertEqual(lodSwitchDistance(radius: 3, screenSize: 0.25, fovYDegrees: 90), 12, accuracy: 1e-4) + XCTAssertEqual(lodSwitchDistance(radius: 1, screenSize: 0, fovYDegrees: 90), .greatestFiniteMagnitude) + XCTAssertEqual(lodSwitchDistance(radius: 0, screenSize: 0.5, fovYDegrees: 90), .greatestFiniteMagnitude) + } + + func testALargerPlacementSwitchesFartherAway() async throws { + let packURL = try writePack([ + Placement(name: "Small", asset: "ball", path: "Ball/Ball.untold"), + Placement(name: "Large", asset: "ball", path: "Ball/Ball.untold", position: SIMD3(20, 0, 0), scale: 3), + ]) + + let (_, children) = try await loadPack(packURL) + + let small = try lod(XCTUnwrap(children["Small"])).lodLevels + let large = try lod(XCTUnwrap(children["Large"])).lodLevels + XCTAssertEqual(small.count, large.count) + for index in 0 ..< small.count - 1 { + XCTAssertEqual(large[index].maxDistance, small[index].maxDistance * 3, accuracy: small[index].maxDistance * 3e-3) + } + } + + func testEveryNodeOfAModelGetsItsLevels() async throws { + let packURL = try writePack([Placement(name: "Stadium", asset: "stadium", path: "Stadium/Stadium.untold")]) + let chain = try XCTUnwrap(loadUntoldPack(url: packURL)?.lodChains?["Stadium/Stadium.untold"]) + + let (_, children) = try await loadPack(packURL) + + let stadium = try XCTUnwrap(children["Stadium"]) + let nodes = try XCTUnwrap(scene.get(component: ScenegraphComponent.self, for: stadium)).children + .filter { hasComponent(entityId: $0, componentType: RenderComponent.self) } + XCTAssertEqual(nodes.count, 7) + var firstSwitch: Float? + for node in nodes { + let component = try lod(node) + XCTAssertEqual(component.lodLevels.count, chain.count + 1, getEntityName(entityId: node)) + XCTAssertEqual(identities(component.lodLevels[0].mesh), try drawnMeshes(node)) + // The parts of a model change level together: the distances come from the model's size. + XCTAssertEqual(component.lodLevels[0].maxDistance, firstSwitch ?? component.lodLevels[0].maxDistance) + firstSwitch = component.lodLevels[0].maxDistance + } + } + + // MARK: - What the placements share + + func testPlacementsOfOneModelShareTheMeshesOfEveryLevel() async throws { + let packURL = try writePack([ + Placement(name: "Ball A", asset: "ball", path: "Ball/Ball.untold"), + Placement(name: "Ball B", asset: "ball", path: "Ball/Ball.untold", position: SIMD3(5, 0, 0)), + Placement(name: "Other", asset: "ball", path: "Other/Other.untold", position: SIMD3(10, 0, 0)), + ]) + + let (_, children) = try await loadPack(packURL) + + let first = try lod(XCTUnwrap(children["Ball A"])).lodLevels + let second = try lod(XCTUnwrap(children["Ball B"])).lodLevels + let other = try lod(XCTUnwrap(children["Other"])).lodLevels + XCTAssertEqual(first.count, second.count) + for index in first.indices { + XCTAssertEqual(identities(first[index].mesh), identities(second[index].mesh), "level \(index) is built once") + XCTAssertNotEqual(identities(first[index].mesh), identities(other[index].mesh), "another model has its own") + } + XCTAssertEqual(Set(first.flatMap { identities($0.mesh) }).count, first.count, "every level has buffers of its own") + } + + func testLevelsAreDrawnWithTheModelsMaterials() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")]) + // The level files carry a copy of the model's materials from the time of the + // cook. Change the model's afterwards: the levels must follow the model. + try setRoughnessFactor(0.777, inModelAt: tempRoot.appendingPathComponent("Ball/Ball.untold")) + + let (_, children) = try await loadPack(packURL) + + let levels = try lod(XCTUnwrap(children["Ball"])).lodLevels + XCTAssertGreaterThanOrEqual(levels.count, 3) + XCTAssertEqual(levels[0].mesh[0].submeshes[0].material?.roughnessValue, 0.777) + for level in levels.dropFirst() { + XCTAssertEqual(level.mesh[0].submeshes[0].material?.roughnessValue, 0.777, level.url?.lastPathComponent ?? "") + } + } + + /// Rewrites the roughness factor of the first material of a `.untold` file in place. + private func setRoughnessFactor(_ roughness: Float, inModelAt url: URL) throws { + var data = try Data(contentsOf: url) + let decoded = try UntoldReader().readAsset(from: data) + let table = try XCTUnwrap(decoded.chunks.first { $0.chunkType == .materialTable }) + XCTAssertEqual(table.compressionType, .none) + // nameOffset, flags, baseColorFactor (4), emissiveFactor (3), normalScale, metallicFactor, roughnessFactor. + let offset = Int(table.fileOffset) + 4 * 11 + withUnsafeBytes(of: roughness.bitPattern.littleEndian) { data.replaceSubrange(offset ..< offset + 4, with: $0) } + // The header's hash covers the chunk; a zero hash is not checked. + let hashOffset = 8 + 4 * 11 + 24 + 64 + data.replaceSubrange(hashOffset ..< hashOffset + 32, with: [UInt8](repeating: 0, count: 32)) + XCTAssertEqual(try UntoldReader().readAsset(from: data).materials.first?.roughnessFactor, roughness) + try data.write(to: url) + } + + // MARK: - Switching + + func testTheDistanceToThePlacementSelectsItsLevel() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")]) + let (_, children) = try await loadPack(packURL) + let ball = try XCTUnwrap(children["Ball"]) + let component = try lod(ball) + let camera = makeCamera() + + for (index, level) in component.lodLevels.enumerated() { + let from = index == 0 ? 0 : component.lodLevels[index - 1].maxDistance + let to = min(level.maxDistance, from * 4 + 10) + selectLOD(for: ball, cameraAt: (from + to) / 2, camera: camera) + + XCTAssertEqual(component.currentLOD, index, "between \(from) and \(to)") + XCTAssertEqual(try drawnMeshes(ball), identities(level.mesh)) + } + } + + func testAMaterialEditStaysWithThePlacementThroughItsLevels() async throws { + let packURL = try writePack([ + Placement(name: "Ball A", asset: "ball", path: "Ball/Ball.untold"), + Placement(name: "Ball B", asset: "ball", path: "Ball/Ball.untold", position: SIMD3(500, 0, 0)), + ]) + let (_, children) = try await loadPack(packURL) + let edited = try XCTUnwrap(children["Ball A"]) + let untouched = try XCTUnwrap(children["Ball B"]) + let component = try lod(edited) + let camera = makeCamera() + let far = component.lodLevels[component.lodLevels.count - 2].maxDistance * 2 + let original = getMaterialRoughness(entityId: untouched) + + updateMaterialRoughness(entityId: edited, roughness: 0.123) + selectLOD(for: edited, cameraAt: far, camera: camera) + + XCTAssertEqual(component.currentLOD, component.lodLevels.count - 1) + XCTAssertEqual(getMaterialRoughness(entityId: edited), 0.123, "the coarsest level is drawn with the edited material") + + updateMaterialRoughness(entityId: edited, roughness: 0.456) + selectLOD(for: edited, cameraAt: 0.5, camera: camera) + + XCTAssertEqual(component.currentLOD, 0) + XCTAssertEqual(getMaterialRoughness(entityId: edited), 0.456, "an edit made on a coarse level comes back with the model") + for level in try lod(untouched).lodLevels { + XCTAssertEqual(level.mesh[0].submeshes[0].material?.roughnessValue, original, "the other placement keeps its own") + } + } + + // MARK: - Packs that are not what the manifest says + + func testALevelWhoseFileIsMissingIsLeftOut() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")]) + let chain = try XCTUnwrap(loadUntoldPack(url: packURL)?.lodChains?["Ball/Ball.untold"]) + XCTAssertEqual(chain.count, 3) + try FileManager.default.removeItem(at: tempRoot.appendingPathComponent(chain[0].path)) + + // The pack still loads: the model is there, and so are two of its levels. + let (_, children) = try await loadPack(packURL) + + let levels = try lod(XCTUnwrap(children["Ball"])).lodLevels + XCTAssertEqual(levels.map { $0.url?.lastPathComponent }, ["Ball.untold", "Ball_LOD2.untold", "Ball_LOD3.untold"]) + XCTAssertEqual(levels[1].screenPercentage, chain[1].screenSize) + let model = try NativeFormatLoader().loadAssetSync(from: tempRoot.appendingPathComponent("Ball/Ball.untold")) + let expected = lodSwitchDistance(radius: boundingRadius(of: model.worldBounds), screenSize: chain[1].screenSize, fovYDegrees: fov) + XCTAssertEqual(levels[0].maxDistance, expected, accuracy: expected * 1e-3, "the model stays until the next level that is there") + } + + func testAPackWithoutChainsLoadsWithoutLevels() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")], cookChains: false) + XCTAssertNil(loadUntoldPack(url: packURL)?.lodChains) + + let (_, children) = try await loadPack(packURL) + + let ball = try XCTUnwrap(children["Ball"]) + XCTAssertTrue(hasComponent(entityId: ball, componentType: RenderComponent.self)) + XCTAssertFalse(hasComponent(entityId: ball, componentType: LODComponent.self)) + } +} diff --git a/Tests/UntoldEngineRenderTests/UntoldPackRenderTests.swift b/Tests/UntoldEngineRenderTests/UntoldPackRenderTests.swift index 49c247b18..8f286e850 100644 --- a/Tests/UntoldEngineRenderTests/UntoldPackRenderTests.swift +++ b/Tests/UntoldEngineRenderTests/UntoldPackRenderTests.swift @@ -9,6 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation +import simd @testable import UntoldEngine import XCTest @@ -98,4 +99,224 @@ final class UntoldPackRenderTests: BaseRenderSetup { XCTAssertEqual(getPosition(entityId: armId).x, 2.5, accuracy: 0.0001) XCTAssertTrue(hasComponent(entityId: armId, componentType: RenderComponent.self)) } + + /// Loads a pack and returns its children by display name. + private func loadPack(_ packURL: URL, into root: EntityID? = nil) async throws -> [String: EntityID] { + let rootId = root ?? createEntity() + let expectation = expectation(description: "pack load completes") + setEntityMeshAsync(entityId: rootId, filename: packURL.deletingPathExtension().path, withExtension: "untoldpack") { success in + XCTAssertTrue(success) + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 10.0) + let scenegraph = try XCTUnwrap(scene.get(component: ScenegraphComponent.self, for: rootId)) + return Dictionary(uniqueKeysWithValues: scenegraph.children.map { (getEntityName(entityId: $0) ?? "", $0) }) + } + + /// A model of a pack is created spatial and placed with the transform calls, which + /// update the world matrix at once. Registering its transform again in between asked + /// for a walk over every entity of the scene on the next frame, once per model. + func testModelsUnderAPlacedRootAreInPlaceWithoutATraversal() async throws { + let packURL = try writePack(models: [ + (displayName: "Body", path: "Body/Body.untold", translationX: 0.0), + (displayName: "Arm", path: "Arm/Arm.untold", translationX: 2.5), + ]) + let rootId = createEntity() + translateTo(entityId: rootId, position: simd_float3(10.0, 0.0, -4.0)) + anyTransformDirty = false + + let children = try await loadPack(packURL, into: rootId) + + let armId = try XCTUnwrap(children["Arm"]) + XCTAssertEqual(getLocalPosition(entityId: armId).x, 2.5, accuracy: 0.0001) + XCTAssertEqual(getPosition(entityId: armId).x, 12.5, accuracy: 0.0001) + XCTAssertEqual(getPosition(entityId: armId).z, -4.0, accuracy: 0.0001) + XCTAssertEqual(getEntityParent(entityId: armId), rootId) + XCTAssertFalse(anyTransformDirty, "loading a pack moved nothing that a traversal has to derive") + } + + private func meshIdentity(_ entityId: EntityID) throws -> ObjectIdentifier { + let render = try XCTUnwrap(scene.get(component: RenderComponent.self, for: entityId)) + let mesh = try XCTUnwrap(render.mesh.first) + return ObjectIdentifier(mesh.metalKitMesh) + } + + func testPlacementsOfOneFileShareItsGPUMeshes() async throws { + // Two placements of one file (the exporter writes a repeated model once) and one + // of another file with the same content. + let packURL = try writePack(models: [ + (displayName: "Tree A", path: "Tree/Tree.untold", translationX: 0.0), + (displayName: "Tree B", path: "Tree/Tree.untold", translationX: 5.0), + (displayName: "Rock", path: "Rock/Rock.untold", translationX: 10.0), + ]) + + let children = try await loadPack(packURL) + + let treeA = try XCTUnwrap(children["Tree A"]) + let treeB = try XCTUnwrap(children["Tree B"]) + let rock = try XCTUnwrap(children["Rock"]) + XCTAssertEqual(try meshIdentity(treeA), try meshIdentity(treeB), "both placements draw the same GPU buffers") + XCTAssertNotEqual(try meshIdentity(treeA), try meshIdentity(rock), "another file is built on its own") + XCTAssertEqual(getPosition(entityId: treeB).x, 5.0, accuracy: 0.0001, "each placement keeps its own transform") + } + + func testAMaterialEditOnOnePlacementLeavesTheOtherAlone() async throws { + let packURL = try writePack(models: [ + (displayName: "Tree A", path: "Tree/Tree.untold", translationX: 0.0), + (displayName: "Tree B", path: "Tree/Tree.untold", translationX: 5.0), + ]) + let children = try await loadPack(packURL) + let treeA = try XCTUnwrap(children["Tree A"]) + let treeB = try XCTUnwrap(children["Tree B"]) + let before = scene.get(component: RenderComponent.self, for: treeB)?.mesh.first?.submeshes.first?.material?.roughnessValue + + updateMaterialRoughness(entityId: treeA, roughness: 0.123) + + XCTAssertEqual(scene.get(component: RenderComponent.self, for: treeA)?.mesh.first?.submeshes.first?.material?.roughnessValue, 0.123) + XCTAssertEqual(scene.get(component: RenderComponent.self, for: treeB)?.mesh.first?.submeshes.first?.material?.roughnessValue, before) + } + + // MARK: - Memory budget + + /// The vertex and index buffers behind an entity's meshes, each once. + private func bufferBytes(_ entityIds: [EntityID]) throws -> Int { + var seen = Set() + var bytes = 0 + for entityId in entityIds { + let render = try XCTUnwrap(scene.get(component: RenderComponent.self, for: entityId)) + for allocation in meshBufferAllocations(of: render.mesh) where seen.insert(ObjectIdentifier(allocation.object)).inserted { + bytes += allocation.bytes + } + } + return bytes + } + + func testPlacementsOfOneFileAreInTheMemoryBudgetOnce() async throws { + // Start from an empty ledger: entities of earlier tests leave it only when their + // destruction is finalized. + MemoryBudgetManager.shared.clear() + let packURL = try writePack(models: [ + (displayName: "Tree A", path: "Tree/Tree.untold", translationX: 0.0), + (displayName: "Tree B", path: "Tree/Tree.untold", translationX: 5.0), + (displayName: "Tree C", path: "Tree/Tree.untold", translationX: 10.0), + (displayName: "Rock", path: "Rock/Rock.untold", translationX: 15.0), + ]) + + let children = try await loadPack(packURL) + + let trees = try ["Tree A", "Tree B", "Tree C"].map { try XCTUnwrap(children[$0]) } + let rock = try XCTUnwrap(children["Rock"]) + let treeBytes = try bufferBytes(trees) + let rockBytes = try bufferBytes([rock]) + XCTAssertGreaterThan(treeBytes, 0) + XCTAssertEqual(treeBytes, try bufferBytes([trees[0]]), "the three placements hold one set of buffers") + func tracked() -> Int { + MemoryBudgetManager.shared.getStats().meshMemoryUsed + } + XCTAssertEqual(tracked(), treeBytes + rockBytes, "the budget holds the tree's buffers once, not once per placement") + XCTAssertEqual(MemoryBudgetManager.shared.getMemorySize(for: trees[0]), 0, "evicting one of three placements frees nothing") + + // The buffers stay in the budget while a placement holds them, and leave with the last. + destroyEntity(entityId: trees[0]) + destroyEntity(entityId: trees[1]) + finalizePendingDestroys() + XCTAssertEqual(tracked(), treeBytes + rockBytes) + XCTAssertEqual(MemoryBudgetManager.shared.getMemorySize(for: trees[2]), treeBytes) + + destroyEntity(entityId: trees[2]) + finalizePendingDestroys() + XCTAssertEqual(tracked(), rockBytes) + } + + // MARK: - Image textures + + private func makeTexture() throws -> MTLTexture { + let device = try XCTUnwrap(renderInfo.device) + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm, width: 4, height: 4, mipmapped: false) + return try XCTUnwrap(device.makeTexture(descriptor: descriptor)) + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + lock.withLock { value += 1 } + } + + var count: Int { + lock.withLock { value } + } + } + + func testLoadedImageTexturesAreSharedWhileInUseAndReleasedAfter() throws { + let key = LoadedTextureCache.key(url: tempRoot.appendingPathComponent("shared.png"), isSRGB: true) + let loads = Counter() + try autoreleasepool { + let made = try makeTexture() + let first = LoadedTextureCache.shared.texture(for: key) { + loads.increment() + return made + } + let second = LoadedTextureCache.shared.texture(for: key) { + loads.increment() + return nil + } + XCTAssertTrue(first === made) + XCTAssertTrue(second === made, "a material that asks while another holds the texture gets the same one") + } + XCTAssertEqual(loads.count, 1) + + // Nothing holds it any more: the cache lets it go, and the next material loads it. + let again = LoadedTextureCache.shared.texture(for: key) { + loads.increment() + return nil + } + XCTAssertNil(again) + XCTAssertEqual(loads.count, 2, "the cache does not keep a texture no material holds") + } + + func testAnImageThatSeveralThreadsAskForAtOnceIsDecodedOnce() throws { + // A pack loads eight models at a time, and they share the images of its Textures folder. + let key = LoadedTextureCache.key(url: tempRoot.appendingPathComponent("atlas.png"), isSRGB: true) + let loads = Counter() + let made = try makeTexture() + let results = UnsafeMutablePointer?>.allocate(capacity: 8) + results.initialize(repeating: nil, count: 8) + defer { results.deallocate() } + + DispatchQueue.concurrentPerform(iterations: 8) { index in + let texture = LoadedTextureCache.shared.texture(for: key) { + loads.increment() + Thread.sleep(forTimeInterval: 0.05) + return made + } + results[index] = texture.map { Unmanaged.passRetained($0 as AnyObject) } + } + + XCTAssertEqual(loads.count, 1, "the threads that arrive while it is decoded wait for it") + for index in 0 ..< 8 { + let texture = results[index]?.takeRetainedValue() + XCTAssertTrue(texture === made, "thread \(index)") + } + } + + func testAnImageThatFailsToLoadIsTriedAgain() throws { + let key = LoadedTextureCache.key(url: tempRoot.appendingPathComponent("missing.png"), isSRGB: false) + let loads = Counter() + let made = try makeTexture() + + let failed = LoadedTextureCache.shared.texture(for: key) { + loads.increment() + return nil + } + let loaded = LoadedTextureCache.shared.texture(for: key) { + loads.increment() + return made + } + + XCTAssertNil(failed) + XCTAssertTrue(loaded === made) + XCTAssertEqual(loads.count, 2) + } } diff --git a/Tests/UntoldEngineTests/AnimationReachChainTargetTests.swift b/Tests/UntoldEngineTests/AnimationReachChainTargetTests.swift new file mode 100644 index 000000000..2798a0f0f --- /dev/null +++ b/Tests/UntoldEngineTests/AnimationReachChainTargetTests.swift @@ -0,0 +1,291 @@ +// +// AnimationReachChainTargetTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// Reach IK with a target per chain: two arms, each going its own way. +@MainActor +final class AnimationReachChainTargetTests: XCTestCase { + var entityId: EntityID! + + private let deltaTime: Float = 1.0 / 60.0 + + /// Two arms hanging straight down from shoulders 0.2 m either side of + /// the root at y = 1.4; upper arm and forearm 0.3 m each. + private let jointPaths = [ + "root", + "root/shoulder_l", "root/shoulder_l/elbow_l", "root/shoulder_l/elbow_l/hand_l", + "root/shoulder_r", "root/shoulder_r/elbow_r", "root/shoulder_r/elbow_r/hand_r", + ] + private let parents: [Int?] = [nil, 0, 1, 2, 0, 4, 5] + private let leftShoulder = simd_float3(0.2, 1.4, 0) + private let rightShoulder = simd_float3(-0.2, 1.4, 0) + + override func setUp() async throws { + resetEngineTestState() + + entityId = createEntity() + registerComponent(entityId: entityId, componentType: SkeletonComponent.self) + registerComponent(entityId: entityId, componentType: AnimationComponent.self) + registerComponent(entityId: entityId, componentType: RenderComponent.self) + registerComponent(entityId: entityId, componentType: ScenegraphComponent.self) + registerComponent(entityId: entityId, componentType: LocalTransformComponent.self) + registerComponent(entityId: entityId, componentType: WorldTransformComponent.self) + + let down = simd_float3(0, -0.3, 0) + let localTranslations = [simd_float3(0, 0, 0), leftShoulder, down, down, rightShoulder, down, down] + let bindTranslations = [ + simd_float3(0, 0, 0), + leftShoulder, leftShoulder + down, leftShoulder + down * 2, + rightShoulder, rightShoulder + down, rightShoulder + down * 2, + ] + scene.get(component: SkeletonComponent.self, for: entityId)?.skeleton = Skeleton(runtimeSkeleton: RuntimeSkeleton( + jointPaths: jointPaths, parentIndices: parents, + bindTransforms: bindTranslations.map { simd_float4x4(translation: $0) }, + restTransforms: localTranslations.map { simd_float4x4(translation: $0) } + )) + + let identity = SIMD4(0, 0, 0, 1) + let channels = jointPaths.enumerated().map { index, path in + RuntimeAnimationChannel( + jointPath: path, + translations: [ + .init(time: 0.0, value: localTranslations[index]), + .init(time: 1.0, value: localTranslations[index]), + ], + rotations: [.init(time: 0.0, value: identity), .init(time: 1.0, value: identity)] + ) + } + let animationComponent = scene.get(component: AnimationComponent.self, for: entityId)! + animationComponent.animationClips["hang"] = AnimationClip( + runtimeClip: RuntimeAnimationClip(name: "hang", duration: 1.0, channels: channels) + ) + changeAnimation(entityId: entityId, name: "hang", transitionHalflife: 0) + + setReachIKChains(entityId: entityId, chains: [ + ReachIKChainDescriptor( + shoulderPath: jointPaths[1], elbowPath: jointPaths[2], handPath: jointPaths[3], + bendDirection: simd_float3(0, 0, -1) + ), + ReachIKChainDescriptor( + shoulderPath: jointPaths[4], elbowPath: jointPaths[5], handPath: jointPaths[6], + bendDirection: simd_float3(0, 0, -1) + ), + ]) + } + + override func tearDown() async throws { + destroyEntity(entityId: entityId) + } + + private var animationComponent: AnimationComponent { + scene.get(component: AnimationComponent.self, for: entityId)! + } + + /// Model-space positions of the left and right hand. + private func hands() -> (left: simd_float3, right: simd_float3) { + var positions: [simd_float3] = [] + var rotations: [simd_quatf] = [] + computeForwardKinematics( + pose: animationComponent.localPose, parentIndices: parents, + positions: &positions, rotations: &rotations + ) + return (positions[3], positions[6]) + } + + func testEveryChainReachesItsOwnTarget() { + let left = simd_float3(0.4, 1.2, 0.3) + let right = simd_float3(-0.3, 1.6, 0.2) + setReachIKChainTargets( + entityId: entityId, + targets: [ReachIKChainTarget(position: left), ReachIKChainTarget(position: right)], + halflife: 0 + ) + AnimationSystem.shared.update(deltaTime) + + XCTAssertLessThan(simd_distance(hands().left, left), 0.01) + XCTAssertLessThan(simd_distance(hands().right, right), 0.01) + } + + func testAShoulderTargetIsAnOffsetFromTheChainsShoulder() { + let offset = simd_float3(0.1, -0.2, 0.3) + setReachIKChainTargets( + entityId: entityId, + targets: [ + ReachIKChainTarget(position: offset, space: .shoulder), + ReachIKChainTarget(position: offset, space: .shoulder), + ], + halflife: 0 + ) + AnimationSystem.shared.update(deltaTime) + + XCTAssertLessThan(simd_distance(hands().left, leftShoulder + offset), 0.01) + XCTAssertLessThan(simd_distance(hands().right, rightShoulder + offset), 0.01) + } + + func testWorldAndModelTargetsDifferByTheEntityTransform() { + translateTo(entityId: entityId, position: simd_float3(2, 0, 0)) + let point = simd_float3(0.3, 1.2, 0.3) + setReachIKChainTargets( + entityId: entityId, + targets: [ + ReachIKChainTarget(position: point, space: .model), + ReachIKChainTarget(position: simd_float3(2, 0, 0) + simd_float3(-0.3, 1.2, 0.3), space: .world), + ], + halflife: 0 + ) + AnimationSystem.shared.update(deltaTime) + + XCTAssertLessThan(simd_distance(hands().left, point), 0.01) + XCTAssertLessThan(simd_distance(hands().right, simd_float3(-0.3, 1.2, 0.3)), 0.01) + } + + func testAGroundTargetKeepsTheHeightOfThePose() { + setReachIKChainTargets( + entityId: entityId, + targets: [ReachIKChainTarget(position: simd_float3(0.3, 5, 0.1), space: .modelGround), nil], + halflife: 0, reach: 1 + ) + AnimationSystem.shared.update(deltaTime) + + // Over the spot, as far down as the arm reaches: the height asked + // for (5) is ignored and the pose's own (0.8) is out of reach from + // there, so the hand ends on the line toward it. + let hand = hands().left + let toGoal = simd_normalize(simd_float3(0.3, 0.8, 0.1) - leftShoulder) + XCTAssertLessThan(simd_distance(hand, leftShoulder + toGoal * 0.6), 0.01) + } + + func testAFullReachStraightensTheChain() { + setReachIKChainTargets( + entityId: entityId, + targets: [ReachIKChainTarget(position: simd_float3(2, 0, 0), space: .shoulder), nil], + halflife: 0, reach: 1 + ) + AnimationSystem.shared.update(deltaTime) + + let hand = hands().left + XCTAssertFalse(hand.x.isNaN) + XCTAssertLessThan(simd_distance(hand, leftShoulder + simd_float3(0.6, 0, 0)), 0.01) + } + + func testAChainWithoutATargetKeepsItsPose() { + let rest = rightShoulder + simd_float3(0, -0.6, 0) + setReachIKChainTargets( + entityId: entityId, + targets: [ReachIKChainTarget(position: simd_float3(0.4, 1.2, 0.3)), nil], + halflife: 0 + ) + AnimationSystem.shared.update(deltaTime) + + XCTAssertLessThan(simd_distance(hands().left, simd_float3(0.4, 1.2, 0.3)), 0.01) + XCTAssertLessThan(simd_distance(hands().right, rest), 1e-4) + } + + func testAChainWithoutATargetReachesForTheSharedOne() { + let shared = simd_float3(-0.2, 1.4, 0.4) + setReachIKTarget(entityId: entityId, worldPosition: shared, weight: 1, halflife: 0) + setReachIKChainTargets( + entityId: entityId, + targets: [ReachIKChainTarget(position: simd_float3(0.4, 1.2, 0.3)), nil], + halflife: 0 + ) + AnimationSystem.shared.update(deltaTime) + + XCTAssertLessThan(simd_distance(hands().left, simd_float3(0.4, 1.2, 0.3)), 0.01) + XCTAssertLessThan(simd_distance(hands().right, shared), 0.01) + } + + func testATargetHalflifeOfZeroFollowsTheTargetExactly() { + let first = ReachIKChainTarget(position: simd_float3(0.1, -0.2, 0.3), space: .shoulder) + let second = ReachIKChainTarget(position: simd_float3(-0.1, -0.3, 0.2), space: .shoulder) + setReachIKChainTargets(entityId: entityId, targets: [first, nil], halflife: 0, targetHalflife: 0) + AnimationSystem.shared.update(deltaTime) + setReachIKChainTargets(entityId: entityId, targets: [second, nil], halflife: 0, targetHalflife: 0) + AnimationSystem.shared.update(deltaTime) + + XCTAssertLessThan(simd_distance(hands().left, leftShoulder + second.position), 0.01) + } + + func testFollowingTheChainTargetsExactlyLeavesTheSharedTargetEased() { + // The left hand is tracked and followed exactly; the right takes + // the shared target, which still eases when it jumps. + let tracked = ReachIKChainTarget(position: simd_float3(0.1, -0.2, 0.3), space: .shoulder) + setReachIKTarget(entityId: entityId, worldPosition: simd_float3(-0.4, 1.2, 0.3), weight: 1, halflife: 0) + setReachIKChainTargets(entityId: entityId, targets: [tracked, nil], halflife: 0, targetHalflife: 0) + AnimationSystem.shared.update(deltaTime) + let before = hands().right + + setReachIKTarget(entityId: entityId, worldPosition: simd_float3(0.0, 1.2, 0.3), weight: 1, halflife: 0) + setReachIKChainTargets(entityId: entityId, targets: [tracked, nil], halflife: 0, targetHalflife: 0) + AnimationSystem.shared.update(deltaTime) + + XCTAssertLessThan(simd_distance(hands().left, leftShoulder + tracked.position), 0.01) + XCTAssertLessThan(abs(hands().right.x - before.x), 0.15, "the shared target is crossed over several frames") + for _ in 0 ..< 40 { + AnimationSystem.shared.update(deltaTime) + } + XCTAssertLessThan(simd_distance(hands().right, simd_float3(0.0, 1.2, 0.3)), 0.01) + } + + func testTheReachInfluenceIsTheEntitysWhicheverCallSetItLast() { + // One influence per entity: the call made last sets the reach + // limit for every chain, the shared target's included. + let far = simd_float3(-0.2, 1.4, 5) + setReachIKTarget(entityId: entityId, worldPosition: far, weight: 1, halflife: 0, reach: 0.5) + setReachIKChainTargets( + entityId: entityId, + targets: [ReachIKChainTarget(position: simd_float3(0, 0, 5), space: .shoulder), nil], + halflife: 0, reach: 1 + ) + AnimationSystem.shared.update(deltaTime) + + // Both arms straight (0.6 m), the right one too: the chain call's + // reach, not the shared call's half. + XCTAssertLessThan(simd_distance(hands().right, rightShoulder + simd_float3(0, 0, 0.6)), 0.01) + XCTAssertLessThan(simd_distance(hands().left, leftShoulder + simd_float3(0, 0, 0.6)), 0.01) + } + + func testAJumpingChainTargetIsFollowedOverSeveralFrames() { + let first = ReachIKChainTarget(position: simd_float3(0.2, -0.2, 0.3), space: .shoulder) + let second = ReachIKChainTarget(position: simd_float3(-0.2, -0.2, 0.3), space: .shoulder) + setReachIKChainTargets(entityId: entityId, targets: [first, nil], halflife: 0) + AnimationSystem.shared.update(deltaTime) + let before = hands().left + setReachIKChainTargets(entityId: entityId, targets: [second, nil], halflife: 0) + AnimationSystem.shared.update(deltaTime) + XCTAssertLessThan(abs(hands().left.x - before.x), 0.1) + for _ in 0 ..< 40 { + AnimationSystem.shared.update(deltaTime) + } + XCTAssertLessThan(simd_distance(hands().left, leftShoulder + second.position), 0.01) + } + + func testChainTargetsEaseOutWhenReleased() { + let rest = leftShoulder + simd_float3(0, -0.6, 0) + setReachIKChainTargets( + entityId: entityId, + targets: [ReachIKChainTarget(position: simd_float3(0, 0, 0.5), space: .shoulder), nil], + halflife: 0 + ) + AnimationSystem.shared.update(deltaTime) + setReachIKChainTargets(entityId: entityId, targets: [], halflife: 0.1) + var distances: [Float] = [] + for _ in 0 ..< 60 { + AnimationSystem.shared.update(deltaTime) + distances.append(simd_distance(hands().left, rest)) + } + XCTAssertGreaterThan(distances[5], 0.2) + XCTAssertLessThan(distances[59], 0.01) + XCTAssertTrue(animationComponent.reachIK.chainTargets.isEmpty) + } +} diff --git a/Tests/UntoldEngineTests/ComponentPublicationTests.swift b/Tests/UntoldEngineTests/ComponentPublicationTests.swift new file mode 100644 index 000000000..692395366 --- /dev/null +++ b/Tests/UntoldEngineTests/ComponentPublicationTests.swift @@ -0,0 +1,248 @@ +// +// ComponentPublicationTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +private final class PublishedComponent: Component { + static let initial = 0x00C0_FFEE + + var value = PublishedComponent.initial + + required init() {} +} + +/// A component that is a value: nothing in `Component` says it has to be a class. +private struct ValueComponent: Component { + var first = 1 + var second = 2 + var third = 3 + + init() {} +} + +/// Whether the reader on the other thread should stop, and what it found. +private final class ReaderState: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + private var reads = 0 + private var unexpected = 0 + + func stop() { + lock.withLock { stopped = true } + } + + var isStopped: Bool { + lock.withLock { stopped } + } + + func record(reads newReads: Int, unexpected newUnexpected: Int) { + lock.withLock { + reads += newReads + unexpected += newUnexpected + } + } + + var totals: (reads: Int, unexpected: Int) { + lock.withLock { (reads, unexpected) } + } +} + +/// A copy of the scene is read after the scene's lock is released, and a slot the copy +/// says is in use may get a new component meanwhile. A component used to be written +/// into its slot with a plain store and read with a plain load, so nothing ordered the +/// read after the new object's initialization: a data race. +/// +/// The threaded tests hold one copy on a reader thread while this thread puts new +/// components in the slots the copy reads. They say the most under Thread Sanitizer: +/// +/// swift test --sanitize=thread --filter ComponentPublicationTests +@MainActor +final class ComponentPublicationTests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + } + + override func tearDown() async throws { + resetEngineTestState() + } + + // MARK: - A slot + + func testASlotOwnsItsObjectOnceAndReadsLeaveItSo() { + let slot = UnsafeMutableRawPointer.allocate( + byteCount: MemoryLayout.stride, alignment: MemoryLayout.alignment + ) + defer { slot.deallocate() } + weak var watched: PublishedComponent? + do { + let component = PublishedComponent() + watched = component + ComponentSlot.store(component, in: slot, asReference: true) + } + XCTAssertNotNil(watched, "the slot keeps the component") + + for _ in 0 ..< 3 { + let read = ComponentSlot.load(from: slot, as: PublishedComponent.self, asReference: true) + XCTAssertTrue(read === watched) + } + + // The slot's reference is the only one left: without it the component goes. + Unmanaged.fromOpaque(slot.load(as: UnsafeRawPointer.self)).release() + XCTAssertNil(watched) + } + + func testOnlyAComponentThatIsAnObjectIsKeptAsOneReference() { + XCTAssertTrue(ComponentSlot.holdsReference(PublishedComponent.self)) + XCTAssertFalse(ComponentSlot.holdsReference(ValueComponent.self)) + } + + func testAComponentThatIsAValueIsKeptInPlace() { + let entity = createEntity() + XCTAssertEqual(scene.assign(to: entity, component: ValueComponent.self)?.second, 2) + + let read = scene.get(component: ValueComponent.self, for: entity) + + XCTAssertEqual(read?.first, 1) + XCTAssertEqual(read?.second, 2) + XCTAssertEqual(read?.third, 3) + } + + func testTheSceneReadsTheComponentItWasGivenLast() { + let entity = createEntity() + let first = scene.assign(to: entity, component: PublishedComponent.self) + XCTAssertNotNil(first) + XCTAssertTrue(scene.get(component: PublishedComponent.self, for: entity) === first) + + let second = scene.assign(to: entity, component: PublishedComponent.self) + + XCTAssertNotNil(second) + XCTAssertFalse(second === first, "an entity given the component again gets a new one") + XCTAssertTrue(scene.get(component: PublishedComponent.self, for: entity) === second) + } + + func testACopyOfTheSceneReadsTheComponentItsEntityWasGivenAgain() { + // A copy shares the scene's slots: it reads what the slot holds now. + let entity = createEntity() + _ = scene.assign(to: entity, component: PublishedComponent.self) + let copy = scene + + let second = scene.assign(to: entity, component: PublishedComponent.self) + + XCTAssertTrue(copy.get(component: PublishedComponent.self, for: entity) === second) + } + + // MARK: - A reader on another thread + + /// Runs `change` on this thread, `rounds` times, while a reader thread reads the + /// component of every entity over and over through one copy of the scene, taken + /// before the first change. The reader stops after the last change. + private func readThroughOneCopy( + of entities: [EntityID], rounds: Int, while change: (Int) -> Void + ) -> (reads: Int, unexpected: Int) { + let copyTaken = DispatchSemaphore(value: 0) + let state = ReaderState() + let finished = expectation(description: "the reader stopped") + + Thread.detachNewThread { + let copy = scene + copyTaken.signal() + var reads = 0 + var unexpected = 0 + while !state.isStopped { + for entity in entities { + guard let component = copy.get(component: PublishedComponent.self, for: entity) else { continue } + reads += 1 + if component.value != PublishedComponent.initial { + unexpected += 1 + } + } + } + state.record(reads: reads, unexpected: unexpected) + finished.fulfill() + } + + copyTaken.wait() + for round in 0 ..< rounds { + change(round) + } + state.stop() + wait(for: [finished], timeout: 60) + return state.totals + } + + func testAReaderHoldingACopyReadsWhileItsEntitiesAreGivenTheComponentAgain() { + // What a loader does to the transform components: the entity has the component, + // and it is registered once more. + let entities = (0 ..< 16).map { _ in createEntity() } + for entity in entities { + registerComponent(entityId: entity, componentType: PublishedComponent.self) + } + let rounds = 200 + + let totals = readThroughOneCopy(of: entities, rounds: rounds) { _ in + for entity in entities { + registerComponent(entityId: entity, componentType: PublishedComponent.self) + } + } + + XCTAssertGreaterThan(totals.reads, 0) + XCTAssertEqual(totals.unexpected, 0) + } + + func testAReaderHoldingACopyReadsWhileTheComponentIsRemovedAndGivenAgain() { + // What replacing an entity's mesh does to its render component: the copy still + // says the entity has the component, and its slot gets a new one. + let entities = (0 ..< 16).map { _ in createEntity() } + for entity in entities { + registerComponent(entityId: entity, componentType: PublishedComponent.self) + } + let rounds = 200 + + let totals = readThroughOneCopy(of: entities, rounds: rounds) { _ in + for entity in entities { + scene.remove(component: PublishedComponent.self, from: entity) + registerComponent(entityId: entity, componentType: PublishedComponent.self) + } + } + + XCTAssertGreaterThan(totals.reads, 0) + XCTAssertEqual(totals.unexpected, 0) + } + + func testAReaderHoldingACopyReadsWhileItsEntitiesAreDestroyedAndTheirIndicesReused() { + // The copy still holds the entities that were destroyed, and new entities take + // their indices and their slots. + var entities = (0 ..< 16).map { _ in createEntity() } + for entity in entities { + registerComponent(entityId: entity, componentType: PublishedComponent.self) + } + let readerEntities = entities + let rounds = 200 + + let totals = readThroughOneCopy(of: readerEntities, rounds: rounds) { _ in + for entity in entities { + destroyEntity(entityId: entity) + } + finalizePendingDestroys() + entities = entities.map { _ in createEntity() } + for entity in entities { + registerComponent(entityId: entity, componentType: PublishedComponent.self) + } + } + + XCTAssertEqual( + Set(entities.map { getEntityIndex($0) }), Set(readerEntities.map { getEntityIndex($0) }), + "the new entities took the indices of the destroyed ones" + ) + XCTAssertGreaterThan(totals.reads, 0) + XCTAssertEqual(totals.unexpected, 0) + } +} diff --git a/Tests/UntoldEngineTests/EngineCreatedEntityTests.swift b/Tests/UntoldEngineTests/EngineCreatedEntityTests.swift new file mode 100644 index 000000000..70af2933b --- /dev/null +++ b/Tests/UntoldEngineTests/EngineCreatedEntityTests.swift @@ -0,0 +1,83 @@ +// +// EngineCreatedEntityTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// Entities the engine creates for a load keep the transform and scene graph components +/// `createEntity()` gave them. Registering them again made three more objects per entity +/// and asked for a scene graph traversal (a walk over every entity) on the next frame, +/// although the transform calls the loaders use update the world matrix at once. +@MainActor +final class EngineCreatedEntityTests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + anyTransformDirty = false + } + + override func tearDown() async throws { + resetEngineTestState() + anyTransformDirty = false + } + + private func assertVector( + _ value: simd_float3, + equals expected: simd_float3, + accuracy: Float = 0.0001, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(value.x, expected.x, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(value.y, expected.y, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(value.z, expected.z, accuracy: accuracy, file: file, line: line) + } + + func testAStreamingEntityIsSpatialAndAsksForNoTraversal() { + let entity = createStreamingEntity(filename: "model", withExtension: "untold") + + XCTAssertTrue(hasComponent(entityId: entity, componentType: LocalTransformComponent.self)) + XCTAssertTrue(hasComponent(entityId: entity, componentType: WorldTransformComponent.self)) + XCTAssertTrue(hasComponent(entityId: entity, componentType: ScenegraphComponent.self)) + XCTAssertFalse(anyTransformDirty, "an entity at the identity transform has nothing to derive") + + translateTo(entityId: entity, position: simd_float3(4.0, 5.0, 6.0)) + + assertVector(getPosition(entityId: entity), equals: simd_float3(4.0, 5.0, 6.0)) + } + + func testADeserializedHierarchyIsPlacedWithoutATraversal() throws { + let parent = createEntity() + setEntityName(entityId: parent, name: "Parent") + translateTo(entityId: parent, position: simd_float3(3.0, 0.0, -2.0)) + scaleTo(entityId: parent, scale: simd_float3(2.0, 2.0, 2.0)) + + let child = createEntity() + setEntityName(entityId: child, name: "Child") + setParent(childId: child, parentId: parent) + translateTo(entityId: child, position: simd_float3(1.0, 0.5, 0.0)) + + let snapshot = serializeScene() + resetEngineTestState() + anyTransformDirty = false + + deserializeScene(sceneData: snapshot, meshLoadingMode: .sync) + + let restoredParent = try XCTUnwrap(reverseEntityNameMap["Parent"]?.first) + let restoredChild = try XCTUnwrap(reverseEntityNameMap["Child"]?.first) + XCTAssertEqual(getEntityParent(entityId: restoredChild), restoredParent) + XCTAssertEqual(getEntityChildren(parentId: restoredParent), [restoredChild]) + assertVector(getLocalPosition(entityId: restoredChild), equals: simd_float3(1.0, 0.5, 0.0)) + assertVector(getPosition(entityId: restoredParent), equals: simd_float3(3.0, 0.0, -2.0)) + // World position of the child: the parent's translation plus its own, scaled by the parent. + assertVector(getPosition(entityId: restoredChild), equals: simd_float3(5.0, 1.0, -2.0)) + XCTAssertFalse(anyTransformDirty, "the transforms were applied with the calls that update the world matrix at once") + } +} diff --git a/Tests/UntoldEngineTests/ExternalRenderExtensionPackageTests.swift b/Tests/UntoldEngineTests/ExternalRenderExtensionPackageTests.swift index 81d535616..6268721f4 100644 --- a/Tests/UntoldEngineTests/ExternalRenderExtensionPackageTests.swift +++ b/Tests/UntoldEngineTests/ExternalRenderExtensionPackageTests.swift @@ -1,3 +1,13 @@ +// +// ExternalRenderExtensionPackageTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + import Foundation import XCTest diff --git a/Tests/UntoldEngineTests/GaussianRuntimeLimitsTests.swift b/Tests/UntoldEngineTests/GaussianRuntimeLimitsTests.swift index 3e4150cd3..50cadb214 100644 --- a/Tests/UntoldEngineTests/GaussianRuntimeLimitsTests.swift +++ b/Tests/UntoldEngineTests/GaussianRuntimeLimitsTests.swift @@ -1,3 +1,13 @@ +// +// GaussianRuntimeLimitsTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // GaussianRuntimeLimitsTests.swift // UntoldEngine diff --git a/Tests/UntoldEngineTests/LODSystemTests.swift b/Tests/UntoldEngineTests/LODSystemTests.swift index 8b4a148a6..185e3cfed 100644 --- a/Tests/UntoldEngineTests/LODSystemTests.swift +++ b/Tests/UntoldEngineTests/LODSystemTests.swift @@ -331,6 +331,41 @@ final class LODSystemTests: XCTestCase { XCTAssertEqual(adjustedDownward, 95.0, "Downward threshold should include hysteresis") } + private struct DistanceLevel: LODDistanceLevel { + var maxDistance: Float + } + + private func selectedLOD(distance: Float, current: Int, thresholds: [Float], hysteresis: Float = 5) -> Int { + selectLODIndex( + levels: thresholds.map(DistanceLevel.init), + distance: distance, + currentLOD: current, + forcedLOD: nil, + lodBias: 1, + hysteresis: hysteresis, + globalDistances: [] + ) + } + + func testHysteresisHoldsACoarserLevelJustInsideAThreshold() { + // Switching at 100 with a hysteresis of 5: going away the level changes at 100, + // coming back it changes at 95. + XCTAssertEqual(selectedLOD(distance: 99, current: 0, thresholds: [100, 200]), 0) + XCTAssertEqual(selectedLOD(distance: 101, current: 0, thresholds: [100, 200]), 1) + XCTAssertEqual(selectedLOD(distance: 97, current: 1, thresholds: [100, 200]), 1) + XCTAssertEqual(selectedLOD(distance: 94, current: 1, thresholds: [100, 200]), 0) + } + + func testHysteresisNeverSwallowsALevelsSwitchDistance() { + // A small object switches at 3; a hysteresis of 5 would put the way back to + // level 0 at a negative distance and leave it on level 1 for good. + XCTAssertEqual(selectedLOD(distance: 4, current: 0, thresholds: [3, 8]), 1) + XCTAssertEqual(selectedLOD(distance: 2.9, current: 1, thresholds: [3, 8]), 1, "still inside the band") + XCTAssertEqual(selectedLOD(distance: 2.6, current: 1, thresholds: [3, 8]), 0, "a tenth of the switch distance is the most it holds") + XCTAssertEqual(selectedLOD(distance: 0.5, current: 2, thresholds: [3, 8]), 0) + XCTAssertEqual(lodHysteresisDistanceShare, 0.1) + } + // MARK: - LOD Bias Tests func testLODBias() { diff --git a/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift b/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift index 6346ce5ec..6022b8587 100644 --- a/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift +++ b/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift @@ -525,4 +525,176 @@ final class MemoryBudgetManagerTests: XCTestCase { "clear() must reset inFlightTextureReservation to zero") manager.releaseTextureReservation(sizeBytes: halfBudget) // cleanup } + + // MARK: - Allocations several entities hold + + private func allocation(_ object: AnyObject, _ bytes: Int) -> GPUAllocation { + GPUAllocation(object: object, bytes: bytes) + } + + func testABufferSeveralEntitiesHoldIsCountedOnce() { + // 500 placements of one model: one set of buffers, one texture. + let vertices = NSObject(), indices = NSObject(), texture = NSObject() + for entity in 1 ... 500 { + manager.registerMesh( + entityId: EntityID(entity), + meshAllocations: [allocation(vertices, 3000), allocation(indices, 1000)], + textureAllocations: [allocation(texture, 8000)] + ) + } + + let stats = manager.getStats() + XCTAssertEqual(stats.meshMemoryUsed, 4000) + XCTAssertEqual(stats.textureMemoryUsed, 8000) + XCTAssertEqual(stats.trackedEntityCount, 500) + } + + func testAnEntitysOwnBuffersAddToTheOnesItShares() { + let shared = NSObject(), ownA = NSObject(), ownB = NSObject() + manager.registerMesh(entityId: 1, meshAllocations: [allocation(shared, 1000), allocation(ownA, 200)], textureAllocations: []) + manager.registerMesh(entityId: 2, meshAllocations: [allocation(shared, 1000), allocation(ownB, 50)], textureAllocations: []) + + XCTAssertEqual(manager.getStats().meshMemoryUsed, 1250) + } + + func testABufferAnEntityUsesTwiceIsCountedOnce() { + // The submeshes of a mesh can draw from one index buffer. + let indices = NSObject() + manager.registerMesh(entityId: 1, meshAllocations: [allocation(indices, 600), allocation(indices, 600)], textureAllocations: []) + + XCTAssertEqual(manager.getStats().meshMemoryUsed, 600) + } + + func testASharedBufferStaysInTheTotalUntilItsLastHolderLeaves() { + let buffer = NSObject(), texture = NSObject() + for entity in 1 ... 3 { + manager.registerMesh(entityId: EntityID(entity), meshAllocations: [allocation(buffer, 1000)], textureAllocations: [allocation(texture, 500)]) + } + + manager.unregisterMesh(entityId: 1) + manager.unregisterMesh(entityId: 2) + XCTAssertEqual(manager.getStats().meshMemoryUsed, 1000) + XCTAssertEqual(manager.getStats().textureMemoryUsed, 500) + + manager.unregisterMesh(entityId: 3) + XCTAssertEqual(manager.getStats().meshMemoryUsed, 0) + XCTAssertEqual(manager.getStats().textureMemoryUsed, 0) + } + + func testRegisteringAnEntityAgainReplacesWhatItHeld() { + let first = NSObject(), second = NSObject() + manager.registerMesh(entityId: 1, meshAllocations: [allocation(first, 1000)], textureAllocations: []) + manager.registerMesh(entityId: 2, meshAllocations: [allocation(first, 1000)], textureAllocations: []) + + manager.registerMesh(entityId: 1, meshAllocations: [allocation(second, 300)], textureAllocations: []) + XCTAssertEqual(manager.getStats().meshMemoryUsed, 1300, "entity 2 still holds the first buffer") + + manager.registerMesh(entityId: 2, meshAllocations: [allocation(second, 300)], textureAllocations: []) + XCTAssertEqual(manager.getStats().meshMemoryUsed, 300) + } + + func testASizeRegisteredAsANumberReplacesTheAllocations() { + // The streaming paths register an entity's render component and then its size. + let buffer = NSObject() + manager.registerMesh(entityId: 1, meshAllocations: [allocation(buffer, 1000)], textureAllocations: []) + manager.registerMesh(entityId: 2, meshAllocations: [allocation(buffer, 1000)], textureAllocations: []) + + manager.registerMesh(entityId: 2, meshSizeBytes: 700) + + XCTAssertEqual(manager.getStats().meshMemoryUsed, 1700, "a number is the entity's own") + manager.unregisterMesh(entityId: 1) + XCTAssertEqual(manager.getStats().meshMemoryUsed, 700) + } + + func testEvictingAnEntityFreesOnlyWhatNoOtherEntityHolds() { + let shared = NSObject(), own = NSObject() + manager.registerMesh(entityId: 1, meshAllocations: [allocation(shared, 1000), allocation(own, 200)], textureAllocations: []) + manager.registerMesh(entityId: 2, meshAllocations: [allocation(shared, 1000)], textureAllocations: []) + + XCTAssertEqual(manager.getMemorySize(for: 1), 200) + XCTAssertEqual(manager.getMemorySize(for: 2), 0) + + manager.unregisterMesh(entityId: 1) + XCTAssertEqual(manager.getMemorySize(for: 2), 1000, "the last holder frees the buffer") + XCTAssertEqual(manager.getEvictionCandidatesWithSizes(count: 1).first?.sizeBytes, 1000) + } + + func testEvictionToATargetCountsASharedBufferWithItsLastHolder() { + manager.meshBudget = 100 * 1024 + manager.lowWaterMark = 0.50 + let budget = manager.geometryBudget + manager.textureBudget + let shared = NSObject() + let sharedBytes = budget * 6 / 10 + + // Three old placements of one model, then an entity of its own, the most recent. + for entity in 1 ... 3 { + manager.beginFrame() + manager.registerMesh(entityId: EntityID(entity), meshAllocations: [allocation(shared, sharedBytes)], textureAllocations: []) + } + manager.beginFrame() + manager.registerMesh(entityId: 4, meshSizeBytes: budget * 3 / 10) + + // 90 % of the budget is in use and 40 % has to go. The first two placements free + // nothing; the model goes with the third. + XCTAssertEqual(manager.getEvictionCandidatesToTarget(), [1, 2, 3]) + } + + func testATextureSizeUpdateLetsGoOfTheTexturesTheEntityShared() { + let texture = NSObject() + manager.registerMesh(entityId: 1, meshAllocations: [], textureAllocations: [allocation(texture, 8000)]) + manager.registerMesh(entityId: 2, meshAllocations: [], textureAllocations: [allocation(texture, 8000)]) + + // Texture streaming gave entity 2 textures of its own. + manager.updateTextureSizeBytes(entityId: 2, newSizeBytes: 2000) + XCTAssertEqual(manager.getStats().textureMemoryUsed, 10000) + + manager.unregisterMesh(entityId: 1) + XCTAssertEqual(manager.getStats().textureMemoryUsed, 2000) + manager.updateTextureSizeBytes(entityId: 2, newSizeBytes: 500) + XCTAssertEqual(manager.getStats().textureMemoryUsed, 500) + } + + func testAnAllocationThatDiedIsNotMistakenForTheOneAtItsAddress() throws { + // Entity 1 stays registered with a buffer that is then released, as when its + // meshes are replaced behind the ledger's back. Whatever is allocated at that + // address next is another buffer. + var first: NSObject? = NSObject() + let address = try ObjectIdentifier(XCTUnwrap(first)) + try manager.registerMesh(entityId: 1, meshAllocations: [allocation(XCTUnwrap(first), 1000)], textureAllocations: []) + first = nil + + var kept: [NSObject] = [] + var second: NSObject? + for _ in 0 ..< 10000 { + let candidate = NSObject() + if ObjectIdentifier(candidate) == address { + second = candidate + break + } + kept.append(candidate) + } + guard let reused = second else { + throw XCTSkip("the allocator did not hand the address out again in 10,000 tries") + } + _ = kept + + manager.registerMesh(entityId: 2, meshAllocations: [allocation(reused, 300)], textureAllocations: []) + XCTAssertEqual(manager.getStats().meshMemoryUsed, 1300) + + manager.unregisterMesh(entityId: 1) + XCTAssertEqual(manager.getStats().meshMemoryUsed, 300, "entity 1 lets go of its own record, not of entity 2's buffer") + manager.unregisterMesh(entityId: 2) + XCTAssertEqual(manager.getStats().meshMemoryUsed, 0) + } + + func testClearForgetsTheAllocations() { + let buffer = NSObject() + manager.registerMesh(entityId: 1, meshAllocations: [allocation(buffer, 1000)], textureAllocations: []) + + manager.clear() + manager.registerMesh(entityId: 2, meshAllocations: [allocation(buffer, 1000)], textureAllocations: []) + + XCTAssertEqual(manager.getStats().meshMemoryUsed, 1000) + XCTAssertEqual(manager.getMemorySize(for: 2), 1000) + } } diff --git a/Tests/UntoldEngineTests/PSVR2InputSystemTests.swift b/Tests/UntoldEngineTests/PSVR2InputSystemTests.swift index 9e85bc13b..f4dea13c0 100644 --- a/Tests/UntoldEngineTests/PSVR2InputSystemTests.swift +++ b/Tests/UntoldEngineTests/PSVR2InputSystemTests.swift @@ -1,3 +1,13 @@ +// +// PSVR2InputSystemTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + // // PSVR2InputSystemTests.swift // UntoldEngineTests diff --git a/Tests/UntoldEngineTests/SmallObjectCullingTests.swift b/Tests/UntoldEngineTests/SmallObjectCullingTests.swift new file mode 100644 index 000000000..eb5689ca0 --- /dev/null +++ b/Tests/UntoldEngineTests/SmallObjectCullingTests.swift @@ -0,0 +1,119 @@ +// +// SmallObjectCullingTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +final class SmallObjectCullingTests: XCTestCase { + private var savedPixels: Float = 0 + + override func setUp() { + super.setUp() + savedPixels = SmallObjectCulling.minimumPixels + } + + override func tearDown() { + SmallObjectCulling.minimumPixels = savedPixels + super.tearDown() + } + + /// A view 1,000 pixels high with a 90° field of view: a sphere of radius r at + /// distance d is 1000 * r / d pixels tall. + private func culling(pixels: Float, camera: simd_float3 = .zero) -> SmallObjectCulling { + SmallObjectCulling(cameraPosition: camera, minimumPixels: pixels, viewportHeight: 1000, tanHalfFovY: 1) + } + + func testASphereUnderTheLimitIsCulledAndOneOverItIsNot() { + let test = culling(pixels: 2) + + // Radius 0.5 at 200 is 2.5 pixels; at 300 it is 1.67. + XCTAssertFalse(test.culls(center: simd_float3(0, 0, -200), radius: 0.5)) + XCTAssertTrue(test.culls(center: simd_float3(0, 0, -300), radius: 0.5)) + // The same sphere as far away, off to the side. + XCTAssertTrue(test.culls(center: simd_float3(300, 0, 0), radius: 0.5)) + } + + func testTheLimitScalesWithTheViewAndTheFieldOfView() { + let center = simd_float3(0, 0, -300) + // Twice the pixels for the same field of view: 3.33 pixels now. + let largerView = SmallObjectCulling(cameraPosition: .zero, minimumPixels: 2, viewportHeight: 2000, tanHalfFovY: 1) + XCTAssertFalse(largerView.culls(center: center, radius: 0.5)) + // A narrower field of view magnifies: 3.33 pixels as well. + let narrower = SmallObjectCulling(cameraPosition: .zero, minimumPixels: 2, viewportHeight: 1000, tanHalfFovY: 0.5) + XCTAssertFalse(narrower.culls(center: center, radius: 0.5)) + } + + func testDistanceIsMeasuredFromTheCamera() { + let test = culling(pixels: 2, camera: simd_float3(0, 0, -250)) + + XCTAssertFalse(test.culls(center: simd_float3(0, 0, -300), radius: 0.5), "50 away: 10 pixels") + XCTAssertTrue(test.culls(center: simd_float3(0, 0, 50), radius: 0.5), "300 away: 1.67 pixels") + } + + func testNothingIsCulledWithALimitOfZero() { + let test = culling(pixels: 0) + + XCTAssertFalse(test.culls(center: simd_float3(0, 0, -100_000), radius: 0.001)) + XCTAssertFalse(culling(pixels: -3).culls(center: simd_float3(0, 0, -100_000), radius: 0.001)) + } + + func testAnObjectTheCameraIsInsideOfIsNeverCulled() { + let test = culling(pixels: 500) + + XCTAssertFalse(test.culls(center: simd_float3(0, 0, -1), radius: 2)) + XCTAssertFalse(test.culls(center: .zero, radius: 0.001)) + } + + /// Bounds that are a single point do not say how large the object draws. + func testBoundsWithNoSizeAreNeverCulled() { + let test = culling(pixels: 2) + let point = simd_float3(5, 5, -1000) + + XCTAssertFalse(test.culls(center: point, radius: 0)) + XCTAssertFalse(test.culls(worldMin: point, worldMax: point)) + } + + func testABoxIsMeasuredByTheSphereAroundIt() { + let test = culling(pixels: 2) + // A long thin beam: 2 x 0.02 x 0.02. Its sphere has a radius just over 1. + let low = simd_float3(-1, -0.01, -0.01) + let high = simd_float3(1, 0.01, 0.01) + let offset = simd_float3(0, 0, -400) + + XCTAssertFalse(test.culls(worldMin: low + offset, worldMax: high + offset), "2.5 pixels long, however thin") + XCTAssertTrue(test.culls(worldMin: low + offset * 2, worldMax: high + offset * 2), "1.25 pixels long") + } + + func testAViewWithoutHeightCullsNothing() { + let test = SmallObjectCulling(cameraPosition: .zero, minimumPixels: 2, viewportHeight: 0, tanHalfFovY: 1) + + XCTAssertFalse(test.culls(center: simd_float3(0, 0, -1000), radius: 0.001)) + } + + func testTheSettingIsOnePixelByDefaultAndNeverNegative() { + XCTAssertEqual(savedPixels, 1, "the default") + + setRendering(.smallObjectCulling(pixels: 4)) + XCTAssertEqual(getSmallObjectCullingPixels(), 4) + + setRendering(.smallObjectCulling(pixels: -2)) + XCTAssertEqual(getSmallObjectCullingPixels(), 0) + + setRendering(.smallObjectCulling(pixels: .nan)) + XCTAssertEqual(getSmallObjectCullingPixels(), 0) + } + + func testThereIsNoTestWhenTheSettingIsOff() { + setRendering(.smallObjectCulling(pixels: 0)) + + XCTAssertNil(SmallObjectCulling.forCurrentFrame()) + } +} diff --git a/Tests/UntoldEngineTests/UntoldBuildCacheTests.swift b/Tests/UntoldEngineTests/UntoldBuildCacheTests.swift new file mode 100644 index 000000000..17ea96206 --- /dev/null +++ b/Tests/UntoldEngineTests/UntoldBuildCacheTests.swift @@ -0,0 +1,191 @@ +// +// UntoldBuildCacheTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +/// The pack loader's single-flight build cache (see UntoldBuildCache): concurrent +/// placements of one file build it once. +final class UntoldBuildCacheTests: XCTestCase { + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + lock.withLock { value += 1 } + } + + var count: Int { + lock.withLock { value } + } + } + + /// A build with nothing in it: what the cache holds does not matter to it. + private func emptyBuild(_ path: String = "/tmp/Tree/Tree.untold") -> UntoldBuild { + UntoldBuild( + runtimeAsset: RuntimeAsset( + sourceURL: URL(fileURLWithPath: path), + sourceKind: .untold, + assetName: "Tree", + worldBounds: RuntimeAABB(min: .zero, max: .zero), + meshGroups: [] + ), + prebuiltMeshes: [:] + ) + } + + private final class Results: @unchecked Sendable { + private let lock = NSLock() + private var builds: [UntoldBuild?] = [] + + func append(_ build: UntoldBuild?) { + lock.withLock { builds.append(build) } + } + + var all: [UntoldBuild?] { + lock.withLock { builds } + } + } + + func testConcurrentRequestsForOneFileBuildItOnce() async { + let cache = UntoldBuildCache() + let builds = Counter() + let results = Results() + let url = URL(fileURLWithPath: "/tmp/Tree/Tree.untold") + let built = emptyBuild() + + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< 32 { + group.addTask { + let build = await cache.build(for: url) { + builds.increment() + Thread.sleep(forTimeInterval: 0.05) + return built + } + results.append(build) + } + } + } + + XCTAssertEqual(builds.count, 1) + XCTAssertEqual(cache.buildCount, 1) + XCTAssertEqual(results.all.count, 32) + XCTAssertTrue(results.all.allSatisfy { $0 === built }, "every caller gets the one build") + } + + func testDifferentFilesBuildAtTheSameTime() async { + // A pack loads eight models at once, so the build of one file must not wait + // for another's: each of these two builds only ends once the other has begun. + let cache = UntoldBuildCache() + let treeBegan = DispatchSemaphore(value: 0) + let rockBegan = DispatchSemaphore(value: 0) + let tree = emptyBuild("/tmp/Tree/Tree.untold") + let rock = emptyBuild("/tmp/Rock/Rock.untold") + let results = Results() + + await withTaskGroup(of: Void.self) { group in + group.addTask { + let build = await cache.build(for: URL(fileURLWithPath: "/tmp/Tree/Tree.untold")) { + treeBegan.signal() + return rockBegan.wait(timeout: .now() + 2) == .success ? tree : nil + } + results.append(build) + } + group.addTask { + let build = await cache.build(for: URL(fileURLWithPath: "/tmp/Rock/Rock.untold")) { + rockBegan.signal() + return treeBegan.wait(timeout: .now() + 2) == .success ? rock : nil + } + results.append(build) + } + } + + XCTAssertEqual(results.all.count, 2) + XCTAssertTrue(results.all.contains { $0 === tree }, "the tree was built while the rock was") + XCTAssertTrue(results.all.contains { $0 === rock }, "the rock was built while the tree was") + } + + func testAnotherPathToTheSameFileIsTheSameBuild() async { + let cache = UntoldBuildCache() + let builds = Counter() + let built = emptyBuild("/tmp/A.untold") + + for path in ["/tmp/A.untold", "/tmp/A.untold", "/tmp/./A.untold"] { + let result = await cache.build(for: URL(fileURLWithPath: path)) { + builds.increment() + return built + } + XCTAssertTrue(result === built) + } + + XCTAssertEqual(builds.count, 1) + } + + func testABuildThatFailsOnceIsTriedAgain() async { + let cache = UntoldBuildCache() + let attempts = Counter() + let url = URL(fileURLWithPath: "/tmp/Tree/Tree.untold") + let built = emptyBuild() + + let first = await cache.build(for: url) { + attempts.increment() + // The first read fails, as a passing I/O error would make it. + return attempts.count == 1 ? nil : built + } + let second = await cache.build(for: url) { + attempts.increment() + return nil + } + + XCTAssertTrue(first === built, "the placement that met the error still gets its model") + XCTAssertTrue(second === built) + XCTAssertEqual(attempts.count, 2, "one retry, and nothing after the build succeeded") + } + + func testAFileThatFailsTwiceIsRememberedAndOtherFilesBuildOnTheirOwn() async { + let cache = UntoldBuildCache() + let attempts = Counter() + + for path in ["/tmp/A.untold", "/tmp/A.untold", "/tmp/./A.untold", "/tmp/B.untold"] { + let result = await cache.build(for: URL(fileURLWithPath: path)) { + attempts.increment() + return nil + } + XCTAssertNil(result) + } + + XCTAssertEqual(attempts.count, 4, "A twice and never again (also through an unnormalised path), B twice") + XCTAssertEqual(cache.buildCount, 2) + } + + func testCallersWaitingForABuildThatFailsAllFallBack() async { + let cache = UntoldBuildCache() + let attempts = Counter() + let results = Results() + let url = URL(fileURLWithPath: "/tmp/Tree/Tree.untold") + + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< 16 { + group.addTask { + let build = await cache.build(for: url) { + attempts.increment() + Thread.sleep(forTimeInterval: 0.03) + return nil + } + results.append(build) + } + } + } + + XCTAssertEqual(attempts.count, 2, "the one caller that builds tries twice; the others wait for it") + XCTAssertEqual(results.all.count, 16) + XCTAssertTrue(results.all.allSatisfy { $0 == nil }) + } +} diff --git a/Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift b/Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift new file mode 100644 index 000000000..cc48e3be6 --- /dev/null +++ b/Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift @@ -0,0 +1,974 @@ +// +// UntoldMeshLODCookerTests.swift +// UntoldEngine +// +// The automatic LOD cook: the levels it writes for a model, what it leaves alone, the +// borders and the aggregates it has to respect, and the pack manifest. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Compression +import Foundation +import simd +@testable import UntoldEngine +@testable import UntoldEngineMeshCook +import XCTest + +final class UntoldMeshLODCookerTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("UntoldMeshLODCookerTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + if let directory { + try? FileManager.default.removeItem(at: directory) + } + } + + // MARK: - A chain + + func testAChainHasThreeLevelsNearTheRatiosAskedFor() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 64)]).write(to: directory, name: "hill") + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + XCTAssertNil(chain.skipped) + XCTAssertEqual(chain.triangleCount, 8192) + XCTAssertEqual(chain.levels.count, 3) + for (level, ratio) in zip(chain.levels, [0.5, 0.15, 0.03] as [Float]) { + let share = Float(level.triangleCount) / Float(chain.triangleCount) + XCTAssertEqual(share, ratio, accuracy: ratio * 0.25, "\(level.url.lastPathComponent) holds \(level.triangleCount) triangles") + } + XCTAssertEqual(chain.levels.map(\.url.lastPathComponent), ["hill_LOD1.untold", "hill_LOD2.untold", "hill_LOD3.untold"]) + } + + func testEachLevelSwitchesAtASmallerScreenSizeThanTheOneBefore() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 64)]).write(to: directory, name: "hill") + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL, options: UntoldMeshLODOptions(pixelsPerTriangle: 4)) + + let sizes = chain.levels.map(\.screenSize) + XCTAssertEqual(sizes, sizes.sorted(by: >)) + // 4 pixels a triangle on a viewport 1080 pixels high. + let first = try XCTUnwrap(chain.levels.first) + XCTAssertEqual(first.screenSize, (4 * Float(first.triangleCount)).squareRoot() / 1080, accuracy: 1e-4) + } + + func testALevelIsTheModelWithItsGeometryReplaced() throws { + let model = LODTestModel(meshes: [.wavySurface(cells: 48), .wavySurface(cells: 32, offset: SIMD3(0, 5, 0), material: 1)]) + let modelURL = try model.write(to: directory, name: "two") + let source = try UntoldReader().readAsset(from: modelURL) + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + for level in chain.levels { + // readAsset checks the content hash and the ranges of every mesh record. + let decoded = try UntoldReader().readAsset(from: level.url) + XCTAssertEqual(decoded.header.fileType, .lod) + XCTAssertEqual(decoded.header.flags & UntoldFileFlags.generatedLODLevel, UntoldFileFlags.generatedLODLevel) + XCTAssertEqual(decoded.header.formatVersion, source.header.formatVersion) + XCTAssertEqual(decoded.header.worldBounds, source.header.worldBounds) + XCTAssertEqual(decoded.entities, source.entities) + XCTAssertEqual(decoded.materials, source.materials) + XCTAssertEqual(decoded.textures, source.textures) + XCTAssertEqual(decoded.stringTableData, source.stringTableData) + XCTAssertEqual(decoded.meshes.count, source.meshes.count) + XCTAssertEqual(decoded.meshes.reduce(0) { $0 + Int($1.indexCount) / 3 }, level.triangleCount) + for (mesh, original) in zip(decoded.meshes, source.meshes) { + XCTAssertEqual(mesh.entityId, original.entityId) + XCTAssertEqual(mesh.materialIndex, original.materialIndex) + XCTAssertEqual(mesh.meshNameOffset, original.meshNameOffset) + XCTAssertLessThan(mesh.indexCount, original.indexCount) + XCTAssertLessThan(mesh.vertexCount, original.vertexCount) + XCTAssertEqual(mesh.edgeIndexCount, 0) + XCTAssertTrue(contains(original.localBounds, mesh.localBounds), "a level stays inside the bounds of its mesh") + } + + let asset = try NativeFormatLoader().loadAssetSync(from: level.url) + XCTAssertEqual(asset.nodes.map(\.id), source.entities.map(\.entityId)) + XCTAssertEqual(asset.nodes.map(\.primitives.count), [1, 1]) + } + } + + func testAShortIndexLevelOfALongIndexMeshReadsBack() throws { + // 261 x 261 vertices need 32-bit indices; 3 % of the triangles do not. + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 260)]).write(to: directory, name: "wide") + let source = try UntoldReader().readAsset(from: modelURL) + XCTAssertEqual(source.meshes[0].indexType, .uint32) + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + let coarsest = try UntoldReader().readAsset(from: XCTUnwrap(chain.levels.last).url) + XCTAssertEqual(coarsest.meshes[0].indexType, .uint16) + XCTAssertNoThrow(try NativeFormatLoader().loadAssetSync(from: XCTUnwrap(chain.levels.last).url)) + } + + func testCompressedGeometryStaysCompressed() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 64)], compressGeometry: true).write(to: directory, name: "packed") + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + XCTAssertEqual(chain.levels.count, 3) + for level in chain.levels { + let decoded = try UntoldReader().readAsset(from: level.url) + XCTAssertEqual(decoded.chunks.first { $0.chunkType == .vertexData }?.compressionType, .lz4) + XCTAssertEqual(decoded.chunks.first { $0.chunkType == .indexData }?.compressionType, .lz4) + XCTAssertNoThrow(try NativeFormatLoader().loadAssetSync(from: level.url)) + } + } + + // MARK: - No chain + + func testAModelUnderTheMinimumGetsNoChain() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 16)]).write(to: directory, name: "small") + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + XCTAssertEqual(chain.triangleCount, 512) + XCTAssertEqual(chain.skipped, .belowMinimumTriangles) + XCTAssertTrue(chain.levels.isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: UntoldMeshLODCooker.levelURL(forModelAt: modelURL, level: 1).path)) + } + + func testAModelThatCannotBeReducedGetsNoChain() throws { + let triangle = LODTestMesh( + positions: [SIMD3(0, 0, 0), SIMD3(1, 0, 0), SIMD3(0, 0, 1)], + normals: [SIMD3](repeating: SIMD3(0, 1, 0), count: 3), + indices: [0, 2, 1] + ) + let modelURL = try LODTestModel(meshes: [triangle]).write(to: directory, name: "triangle") + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL, options: UntoldMeshLODOptions(minimumTriangles: 0)) + + XCTAssertEqual(chain.skipped, .irreducible) + XCTAssertTrue(chain.levels.isEmpty) + } + + func testALevelIsNotGivenLevelsOfItsOwn() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 64)]).write(to: directory, name: "hill") + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + let levelURL = try XCTUnwrap(chain.levels.first).url + + let nested = try UntoldMeshLODCooker.cookChain(forModelAt: levelURL, options: UntoldMeshLODOptions(minimumTriangles: 0)) + + XCTAssertEqual(nested.skipped, .isLevel) + XCTAssertFalse(FileManager.default.fileExists(atPath: UntoldMeshLODCooker.levelURL(forModelAt: levelURL, level: 1).path)) + } + + func testSkinnedAndAnimationFilesKeepTheirMeshes() throws { + let resources = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("UntoldEngineRenderTests/Resources") + for name in ["Models/redplayer/redplayer", "Animations/running/running"] { + let sourceURL = resources.appendingPathComponent(name).appendingPathExtension("untold") + guard FileManager.default.fileExists(atPath: sourceURL.path) else { + throw XCTSkip("cooked fixture not found at \(sourceURL.path)") + } + let modelURL = directory.appendingPathComponent(sourceURL.lastPathComponent) + try FileManager.default.copyItem(at: sourceURL, to: modelURL) + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL, options: UntoldMeshLODOptions(minimumTriangles: 0)) + + XCTAssertEqual(chain.skipped, .deforms, name) + } + } + + func testAnUnreadableModelThrows() throws { + let modelURL = directory.appendingPathComponent("broken.untold") + try Data("not a model".utf8).write(to: modelURL) + + XCTAssertThrowsError(try UntoldMeshLODCooker.cookChain(forModelAt: modelURL)) { error in + guard case UntoldMeshLODError.unreadableModel = error else { + return XCTFail("unexpected error \(error)") + } + } + } + + func testOptionsAreChecked() { + for options in [ + UntoldMeshLODOptions(ratios: []), + UntoldMeshLODOptions(ratios: [0.5, 0.5]), + UntoldMeshLODOptions(ratios: [0.2, 0.5]), + UntoldMeshLODOptions(ratios: [1.0]), + UntoldMeshLODOptions(ratios: [0]), + UntoldMeshLODOptions(minimumTriangles: -1), + UntoldMeshLODOptions(pixelsPerTriangle: 0), + ] { + XCTAssertThrowsError(try options.validate(), "\(options)") + } + XCTAssertNoThrow(try UntoldMeshLODOptions().validate()) + } + + // MARK: - Cooking again + + func testCookingAgainReplacesTheLevelsOfTheCookBefore() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 64)]).write(to: directory, name: "hill") + try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + let third = UntoldMeshLODCooker.levelURL(forModelAt: modelURL, level: 3) + XCTAssertTrue(FileManager.default.fileExists(atPath: third.path)) + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL, options: UntoldMeshLODOptions(ratios: [0.25])) + + XCTAssertEqual(chain.levels.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: UntoldMeshLODCooker.levelURL(forModelAt: modelURL, level: 1).path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: UntoldMeshLODCooker.levelURL(forModelAt: modelURL, level: 2).path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: third.path)) + } + + func testAModelThatNoLongerQualifiesLosesItsLevels() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 64)]).write(to: directory, name: "hill") + try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL, options: UntoldMeshLODOptions(minimumTriangles: 100_000)) + + XCTAssertEqual(chain.skipped, .belowMinimumTriangles) + XCTAssertFalse(FileManager.default.fileExists(atPath: UntoldMeshLODCooker.levelURL(forModelAt: modelURL, level: 1).path)) + } + + func testAFileThatOnlyHasALevelsNameIsLeftAlone() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 64)]).write(to: directory, name: "hill") + // Somebody's own model, named the way a level would be. + let ownURL = try LODTestModel(meshes: [.wavySurface(cells: 8)]).write(to: directory, name: "hill_LOD2") + let own = try Data(contentsOf: ownURL) + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + XCTAssertEqual(chain.skipped, .levelNameTaken) + XCTAssertEqual(try Data(contentsOf: ownURL), own) + XCTAssertFalse(FileManager.default.fileExists(atPath: UntoldMeshLODCooker.levelURL(forModelAt: modelURL, level: 1).path)) + } + + // MARK: - Borders between the meshes of a model + + func testTheBorderTwoMeshesShareStaysClosed() throws { + // Two halves of one wavy surface, each its own mesh and material, meeting on x = 0. + let left = LODTestMesh.wavySurface(cells: 48, origin: SIMD2(-10, -5), size: SIMD2(10, 10)) + let right = LODTestMesh.wavySurface(cells: 48, origin: SIMD2(0, -5), size: SIMD2(10, 10), material: 1) + let modelURL = try LODTestModel(meshes: [left, right]).write(to: directory, name: "halves") + let seam = Set(left.positions.filter { $0.x == 0 }.map(PositionKey.init)) + XCTAssertEqual(seam.count, 49) + XCTAssertEqual(seam, Set(right.positions.filter { $0.x == 0 }.map(PositionKey.init))) + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + XCTAssertEqual(chain.levels.count, 3) + for level in chain.levels { + let meshes = try levelPositions(level.url) + for (name, positions) in zip(["left", "right"], meshes) { + let kept = Set(positions.filter { $0.x == 0 }.map(PositionKey.init)) + XCTAssertEqual(kept, seam, "\(name) half of \(level.url.lastPathComponent) keeps every vertex of the shared border") + } + } + } + + func testABorderThatNoOtherMeshSharesIsSimplified() throws { + let modelURL = try LODTestModel(meshes: [.wavySurface(cells: 48)]).write(to: directory, name: "alone") + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + let coarsest = try levelPositions(XCTUnwrap(chain.levels.last).url)[0] + let border = coarsest.filter { abs($0.x) == 5 || abs($0.z) == 5 } + XCTAssertLessThan(border.count, 4 * 48, "the open border loses vertices like the rest") + } + + // MARK: - Texture seams + + func testOnlyATexturedMeshKeepsItsTextureSeams() { + let surface = LODTestMesh.unwrappedSurface(cells: 32, islands: 2) + func flags(textured: Bool) -> [UInt8] { + var meshes = [UntoldLODMesh(vertexData: surface.vertexData, indices: surface.indices)] + UntoldMeshLODCooker.applyVertexFlags(to: &meshes, transforms: [matrix_identity_float4x4], textured: [textured]) + return meshes[0].flags + } + + // Two lines of 33 vertices cross the surface; each vertex on them has a second + // copy in the next island, and the one at the crossing has three. + XCTAssertEqual(flags(textured: true).filter { $0 != 0 }.count, 33 + 33 + 1) + XCTAssertTrue(flags(textured: false).isEmpty, "texture coordinates nothing reads do not hold the simplifier back") + } + + func testATexturedLevelKeepsItsTrianglesInsideTheirIslands() throws { + let surface = LODTestMesh.unwrappedSurface(cells: 64, islands: 2) + let modelURL = try LODTestModel(meshes: [surface], texturedMaterials: [0]).write(to: directory, name: "atlas") + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + XCTAssertEqual(chain.levels.count, 3) + for level in chain.levels { + let (uvs, indices) = try levelTextureCoordinates(level.url) + for start in stride(from: 0, to: indices.count, by: 3) { + let islands = Set((0 ..< 3).map { LODTestMesh.island(of: uvs[Int(indices[start + $0])], islands: 2) }) + XCTAssertEqual(islands.count, 1, "a triangle of \(level.url.lastPathComponent) reads two islands of the texture") + if islands.count != 1 { + return + } + } + } + } + + func testSeamsThatWouldHoldALevelBackAreReleased() throws { + // Every quad is its own island: with the seams kept, nothing can collapse. + let surface = LODTestMesh.unwrappedSurface(cells: 48, islands: 48) + let modelURL = try LODTestModel(meshes: [surface], texturedMaterials: [0]).write(to: directory, name: "patchwork") + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + XCTAssertEqual(chain.triangleCount, 4608) + XCTAssertEqual(chain.levels.count, 3) + XCTAssertLessThanOrEqual(try XCTUnwrap(chain.levels.first).triangleCount, 2304 * 5 / 4) + } + + // MARK: - Aggregates + + func testAnAggregateIsThinnedEvenlyAndKeepsItsArea() throws { + // A canopy: 1,500 leaf cards of 32 triangles each around a trunk, like a tree + // out of a modelling package. + let leaves = LODTestMesh.cards(count: 1500, cellsPerCard: 4, cardSize: 0.3, radius: 6, seed: 7) + let trunk = LODTestMesh.wavySurface(cells: 24, origin: SIMD2(-0.5, -0.5), size: SIMD2(1, 1), material: 1) + let modelURL = try LODTestModel(meshes: [leaves, trunk]).write(to: directory, name: "tree") + let leafArea = area(positions: leaves.positions, indices: leaves.indices) + XCTAssertEqual(leaves.indices.count / 3, 48000) + + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: modelURL) + + XCTAssertEqual(chain.levels.count, 3) + let source = try UntoldReader().readAsset(from: modelURL) + var cardCounts: [Int] = [] + for level in chain.levels { + let decoded = try UntoldReader().readAsset(from: level.url) + let (positions, indices) = try levelGeometry(level.url, mesh: 0) + XCTAssertEqual( + area(positions: positions, indices: indices), leafArea, accuracy: leafArea * 0.05, + "\(level.url.lastPathComponent) covers what the leaves covered" + ) + XCTAssertTrue(contains(source.meshes[0].localBounds, decoded.meshes[0].localBounds)) + cardCounts.append(componentCount(positions: positions, indices: indices)) + } + // 1,440 triangles are fewer than the cards: the coarsest level cannot keep them all. + XCTAssertEqual(cardCounts.first, 1500, "the first level only collapses inside the cards") + XCTAssertLessThan(try XCTUnwrap(cardCounts.last), 1440) + XCTAssertGreaterThan(try XCTUnwrap(cardCounts.last), 500) + try XCTAssertEqual(Float(XCTUnwrap(chain.levels.last).triangleCount) / Float(chain.triangleCount), 0.03, accuracy: 0.02) + + // Evenly: each octant of the canopy keeps its share of the cards. + let (coarsePositions, coarseIndices) = try levelGeometry(XCTUnwrap(chain.levels.last).url, mesh: 0) + let before = octantShares(positions: leaves.positions, indices: leaves.indices) + let after = octantShares(positions: coarsePositions, indices: coarseIndices) + for (octant, (was, now)) in zip(before, after).enumerated() { + XCTAssertEqual(now, was, accuracy: 0.05, "octant \(octant)") + } + } + + func testAFewLoosePiecesAreNotAnAggregate() throws { + // Ten small cards beside a large surface: details, not a canopy. Their area is + // not handed to the survivors when they go. + let surface = LODTestMesh.wavySurface(cells: 64) + let details = LODTestMesh.cards(count: 10, cellsPerCard: 2, cardSize: 0.05, radius: 4, seed: 3) + let merged = LODTestMesh.merged([surface, details]) + + let parts = UntoldLODMeshParts(mesh: UntoldLODMesh(vertexData: merged.vertexData, indices: merged.indices)) + XCTAssertTrue(parts.clutterIndices.isEmpty) + XCTAssertEqual(parts.bodyIndices.count, merged.indices.count) + + let canopy = LODTestMesh.cards(count: 100, cellsPerCard: 2, cardSize: 0.05, radius: 4, seed: 3) + let canopyParts = UntoldLODMeshParts(mesh: UntoldLODMesh(vertexData: canopy.vertexData, indices: canopy.indices)) + XCTAssertEqual(canopyParts.clutterIndices.count, canopy.indices.count) + XCTAssertTrue(canopyParts.bodyIndices.isEmpty) + // The bounding box of a card at an angle: between its diagonal and that of its cube. + XCTAssertGreaterThanOrEqual(canopyParts.clutterPieceSize, 0.05 * Float(2).squareRoot() - 1e-4) + XCTAssertLessThanOrEqual(canopyParts.clutterPieceSize, 0.05 * Float(3).squareRoot() + 1e-4) + + // The model still gets its chain; the details go with the surface around them. + let modelURL = try LODTestModel(meshes: [merged]).write(to: directory, name: "plate") + XCTAssertEqual(try UntoldMeshLODCooker.cookChain(forModelAt: modelURL).levels.count, 3) + } + + // MARK: - Packs + + func testAPackGetsAChainForEachModelThatNeedsOne() throws { + let packURL = try writePack( + models: [ + ("Tree A", "Site/Tree/Tree.untold", LODTestModel(meshes: [.wavySurface(cells: 64)])), + ("Tree B", "Site/Tree/Tree.untold", nil), + ("Rock", "Site/Rock/Rock.untold", LODTestModel(meshes: [.wavySurface(cells: 40)])), + ("Sign", "Site/Sign/Sign.untold", LODTestModel(meshes: [.wavySurface(cells: 8)])), + ], + extra: ["exporter": "kept"] + ) + let before = try XCTUnwrap(loadUntoldPack(url: packURL)) + + let progress = ProgressLog() + let report = try UntoldMeshLODCooker.cookChains(forPackAt: packURL) { done, total in progress.add(done, total) } + + XCTAssertEqual(report.modelCount, 3, "a model placed twice is one model") + XCTAssertEqual(report.chainCount, 2) + XCTAssertEqual(report.levelCount, 6) + XCTAssertEqual(report.chainedTriangleCount, 8192 + 3200) + XCTAssertTrue(report.failures.isEmpty) + XCTAssertGreaterThan(report.levelBytes, 0) + XCTAssertEqual(progress.calls.map(\.0).sorted(), [1, 2, 3]) + XCTAssertEqual(Set(progress.calls.map(\.1)), [3]) + + let after = try XCTUnwrap(loadUntoldPack(url: packURL)) + XCTAssertEqual(after.formatVersion, before.formatVersion) + XCTAssertEqual(after.sourceAsset, before.sourceAsset) + XCTAssertEqual(after.models.map(\.path), before.models.map(\.path)) + XCTAssertEqual(after.models.map(\.displayName), before.models.map(\.displayName)) + XCTAssertEqual(after.models.map(\.transform), before.models.map(\.transform)) + let chains = try XCTUnwrap(after.lodChains) + XCTAssertEqual(Set(chains.keys), ["Site/Tree/Tree.untold", "Site/Rock/Rock.untold"]) + let tree = try XCTUnwrap(chains["Site/Tree/Tree.untold"]) + XCTAssertEqual(tree.map(\.path), ["Site/Tree/Tree_LOD1.untold", "Site/Tree/Tree_LOD2.untold", "Site/Tree/Tree_LOD3.untold"]) + XCTAssertEqual(tree.map(\.screenSize), tree.map(\.screenSize).sorted(by: >)) + for level in tree { + XCTAssertTrue(FileManager.default.fileExists(atPath: packURL.deletingLastPathComponent().appendingPathComponent(level.path).path)) + XCTAssertNotNil(level.triangles) + XCTAssertNotNil(level.error) + } + + let raw = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: packURL)) as? [String: Any]) + XCTAssertEqual(raw["exporter"] as? String, "kept", "keys the cook does not know stay") + } + + func testCookingAPackAgainGivesTheSameManifest() throws { + let packURL = try writePack(models: [("Tree", "Site/Tree/Tree.untold", LODTestModel(meshes: [.wavySurface(cells: 64)]))]) + try UntoldMeshLODCooker.cookChains(forPackAt: packURL) + let first = try Data(contentsOf: packURL) + + try UntoldMeshLODCooker.cookChains(forPackAt: packURL) + + XCTAssertEqual(try Data(contentsOf: packURL), first) + } + + func testAPackWhoseModelsNeedNoChainHasNoChainTable() throws { + let packURL = try writePack(models: [("Sign", "Site/Sign/Sign.untold", LODTestModel(meshes: [.wavySurface(cells: 8)]))]) + try UntoldMeshLODCooker.cookChains(forPackAt: packURL, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertNotNil(try XCTUnwrap(loadUntoldPack(url: packURL)).lodChains) + + let report = try UntoldMeshLODCooker.cookChains(forPackAt: packURL) + + XCTAssertEqual(report.chainCount, 0) + XCTAssertNil(try XCTUnwrap(loadUntoldPack(url: packURL)).lodChains) + } + + func testAModelThatCannotBeReadIsReportedAndTheOthersStillGetChains() throws { + let packURL = try writePack(models: [ + ("Tree", "Site/Tree/Tree.untold", LODTestModel(meshes: [.wavySurface(cells: 64)])), + ("Gone", "Site/Gone/Gone.untold", nil), + ]) + + let report = try UntoldMeshLODCooker.cookChains(forPackAt: packURL) + + XCTAssertEqual(report.chainCount, 1) + XCTAssertEqual(report.failures.map(\.path), ["Site/Gone/Gone.untold"]) + XCTAssertEqual(try XCTUnwrap(loadUntoldPack(url: packURL)?.lodChains).keys.sorted(), ["Site/Tree/Tree.untold"]) + } + + func testAManifestThatIsNotAPackThrows() throws { + let packURL = directory.appendingPathComponent("list.untoldpack") + try Data("[1, 2, 3]".utf8).write(to: packURL) + + XCTAssertThrowsError(try UntoldMeshLODCooker.cookChains(forPackAt: packURL)) { error in + guard case UntoldMeshLODError.unreadableManifest = error else { + return XCTFail("unexpected error \(error)") + } + } + } + + // MARK: - Helpers + + private final class ProgressLog: @unchecked Sendable { + private let lock = NSLock() + private var entries: [(Int, Int)] = [] + + func add(_ done: Int, _ total: Int) { + lock.withLock { entries.append((done, total)) } + } + + var calls: [(Int, Int)] { + lock.withLock { entries } + } + } + + /// Writes a manifest placing `models`; a nil model reuses (or leaves out) the file at its path. + private func writePack(models: [(name: String, path: String, model: LODTestModel?)], extra: [String: Any] = [:]) throws -> URL { + var entries: [[String: Any]] = [] + for (index, model) in models.enumerated() { + let url = directory.appendingPathComponent(model.path) + if let content = model.model { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try content.data().write(to: url) + } + entries.append([ + "displayName": model.name, + "path": model.path, + "transform": [[1, 0, 0, Double(index) * 2.5], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], + ]) + } + var manifest: [String: Any] = ["formatVersion": 1, "sourceAsset": "Site.blend", "models": entries] + manifest.merge(extra) { current, _ in current } + let packURL = directory.appendingPathComponent("Site.untoldpack") + try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted]).write(to: packURL) + return packURL + } + + private func contains(_ outer: UntoldAABB, _ inner: UntoldAABB) -> Bool { + all(inner.min .>= outer.min - 1e-4) && all(inner.max .<= outer.max + 1e-4) + } + + /// The vertex positions of every mesh of a level file. + private func levelPositions(_ url: URL) throws -> [[SIMD3]] { + let count = try UntoldReader().readAsset(from: url).meshes.count + return try (0 ..< count).map { try levelGeometry(url, mesh: $0).positions } + } + + private func levelGeometry(_ url: URL, mesh index: Int) throws -> (positions: [SIMD3], indices: [UInt32]) { + let asset = try NativeFormatLoader().loadAssetSync(from: url) + let primitive = try XCTUnwrap(asset.nodes.flatMap(\.primitives).dropFirst(index).first) + let positions: [SIMD3] = primitive.vertexData.withUnsafeBytes { raw in + (0 ..< primitive.vertexCount).map { vertex in + SIMD3( + raw.loadUnaligned(fromByteOffset: vertex * 32, as: Float.self), + raw.loadUnaligned(fromByteOffset: vertex * 32 + 4, as: Float.self), + raw.loadUnaligned(fromByteOffset: vertex * 32 + 8, as: Float.self) + ) + } + } + let indices: [UInt32] = primitive.indexData.withUnsafeBytes { raw in + (0 ..< primitive.indexCount).map { position in + primitive.indexFormat == .uint16 + ? UInt32(raw.loadUnaligned(fromByteOffset: position * 2, as: UInt16.self)) + : raw.loadUnaligned(fromByteOffset: position * 4, as: UInt32.self) + } + } + return (positions, indices) + } + + /// The texture coordinates and triangles of the first mesh of a level file. + private func levelTextureCoordinates(_ url: URL) throws -> (uvs: [SIMD2], indices: [UInt32]) { + let asset = try NativeFormatLoader().loadAssetSync(from: url) + let primitive = try XCTUnwrap(asset.nodes.flatMap(\.primitives).first) + let uvs: [SIMD2] = primitive.vertexData.withUnsafeBytes { raw in + (0 ..< primitive.vertexCount).map { vertex in + SIMD2( + Float(Float16(bitPattern: raw.loadUnaligned(fromByteOffset: vertex * 32 + 20, as: UInt16.self))), + Float(Float16(bitPattern: raw.loadUnaligned(fromByteOffset: vertex * 32 + 22, as: UInt16.self))) + ) + } + } + return try (uvs, levelGeometry(url, mesh: 0).indices) + } + + private func area(positions: [SIMD3], indices: [UInt32]) -> Float { + stride(from: 0, to: indices.count, by: 3).reduce(Float(0)) { total, start in + let a = positions[Int(indices[start])] + let b = positions[Int(indices[start + 1])] + let c = positions[Int(indices[start + 2])] + return total + 0.5 * simd_length(simd_cross(b - a, c - a)) + } + } + + /// Connected pieces, joining vertices at the same position. + private func componentCount(positions: [SIMD3], indices: [UInt32]) -> Int { + var firstAtPosition: [PositionKey: Int] = [:] + var first = [Int](repeating: 0, count: positions.count) + for index in positions.indices { + let key = PositionKey(positions[index]) + if let existing = firstAtPosition[key] { + first[index] = existing + } else { + firstAtPosition[key] = index + first[index] = index + } + } + var parent = Array(0 ..< positions.count) + func find(_ start: Int) -> Int { + var node = start + while parent[node] != node { + parent[node] = parent[parent[node]] + node = parent[node] + } + return node + } + for start in stride(from: 0, to: indices.count, by: 3) { + let a = find(first[Int(indices[start])]) + for corner in 1 ... 2 { + let other = find(first[Int(indices[start + corner])]) + if other != a { + parent[other] = a + } + } + } + return Set(indices.map { find(first[Int($0)]) }).count + } + + /// The share of the triangles whose first corner lies in each octant around the origin. + private func octantShares(positions: [SIMD3], indices: [UInt32]) -> [Float] { + var counts = [Float](repeating: 0, count: 8) + for start in stride(from: 0, to: indices.count, by: 3) { + let point = positions[Int(indices[start])] + counts[(point.x > 0 ? 1 : 0) | (point.y > 0 ? 2 : 0) | (point.z > 0 ? 4 : 0)] += 1 + } + let total = max(counts.reduce(0, +), 1) + return counts.map { $0 / total } + } +} + +private struct PositionKey: Hashable { + let x: UInt32 + let y: UInt32 + let z: UInt32 + + init(_ position: SIMD3) { + x = position.x.bitPattern + y = position.y.bitPattern + z = position.z.bitPattern + } +} + +// MARK: - Models for the tests + +/// One mesh of a test model: positions, normals and triangles. +struct LODTestMesh { + var positions: [SIMD3] + var normals: [SIMD3] + var indices: [UInt32] + var material: UInt32 = 0 + /// Texture coordinates per vertex; all zero when empty. + var uvs: [SIMD2] = [] + + var vertexData: Data { + let writer = UntoldBinaryWriter() + for (index, (position, normal)) in zip(positions, normals).enumerated() { + let uv = uvs.isEmpty ? SIMD2(0, 0) : uvs[index] + UntoldPBRStaticVertexV1( + position: position, + normalPacked: UntoldVertexPacking.packNormal(normal), + tangentPacked: UntoldVertexPacking.packTangent(SIMD3(1, 0, 0), handedness: 1), + uv0: SIMD2(Float16(uv.x).bitPattern, Float16(uv.y).bitPattern) + ).encode(to: writer) + } + return writer.data + } + + /// A wavy surface unwrapped into `islands` x `islands` separate squares of the + /// texture: the vertices on the lines between them exist once per island, at the + /// same position with different texture coordinates. + static func unwrappedSurface(cells: Int, islands: Int, material: UInt32 = 0) -> LODTestMesh { + precondition(cells % islands == 0) + let surface = wavySurface(cells: cells) + let cellsPerIsland = cells / islands + var mesh = LODTestMesh(positions: [], normals: [], indices: [], material: material) + for islandRow in 0 ..< islands { + for islandColumn in 0 ..< islands { + let base = UInt32(mesh.positions.count) + for row in 0 ... cellsPerIsland { + for column in 0 ... cellsPerIsland { + let source = (islandRow * cellsPerIsland + row) * (cells + 1) + islandColumn * cellsPerIsland + column + mesh.positions.append(surface.positions[source]) + mesh.normals.append(surface.normals[source]) + // Each island keeps clear of its neighbours in the texture. + mesh.uvs.append(SIMD2( + (Float(islandColumn) + 0.1 + 0.8 * Float(column) / Float(cellsPerIsland)) / Float(islands), + (Float(islandRow) + 0.1 + 0.8 * Float(row) / Float(cellsPerIsland)) / Float(islands) + )) + } + } + let stride = UInt32(cellsPerIsland + 1) + for row in 0 ..< UInt32(cellsPerIsland) { + for column in 0 ..< UInt32(cellsPerIsland) { + let corner = base + row * stride + column + mesh.indices += [corner, corner + stride, corner + 1, corner + 1, corner + stride, corner + stride + 1] + } + } + } + } + return mesh + } + + /// The island of `unwrappedSurface(cells:islands:)` a texture coordinate lies in. + static func island(of uv: SIMD2, islands: Int) -> Int { + Int(uv.y * Float(islands)) * islands + Int(uv.x * Float(islands)) + } + + /// A square of `cells` x `cells` quads in the XZ plane with gentle hills, shared + /// vertices and smooth normals: a connected surface the simplifier can reduce. + static func wavySurface( + cells: Int, + origin: SIMD2 = SIMD2(-5, -5), + size: SIMD2 = SIMD2(10, 10), + offset: SIMD3 = .zero, + material: UInt32 = 0 + ) -> LODTestMesh { + func height(_ x: Float, _ z: Float) -> Float { + 0.4 * sin(x * 0.9) * cos(z * 0.7) + } + var positions: [SIMD3] = [] + var normals: [SIMD3] = [] + for row in 0 ... cells { + for column in 0 ... cells { + // Exact at both ends, so two surfaces that meet share their border bit for bit. + let x = column == cells ? origin.x + size.x : origin.x + size.x * Float(column) / Float(cells) + let z = row == cells ? origin.y + size.y : origin.y + size.y * Float(row) / Float(cells) + positions.append(SIMD3(x, height(x, z), z) + offset) + let slopeX = 0.4 * 0.9 * cos(x * 0.9) * cos(z * 0.7) + let slopeZ = -0.4 * 0.7 * sin(x * 0.9) * sin(z * 0.7) + normals.append(simd_normalize(SIMD3(-slopeX, 1, -slopeZ))) + } + } + var indices: [UInt32] = [] + let stride = UInt32(cells + 1) + for row in 0 ..< UInt32(cells) { + for column in 0 ..< UInt32(cells) { + let corner = row * stride + column + indices += [corner, corner + stride, corner + 1, corner + 1, corner + stride, corner + stride + 1] + } + } + return LODTestMesh(positions: positions, normals: normals, indices: indices, material: material) + } + + /// `count` small square cards of `cellsPerCard` x `cellsPerCard` quads, each its own + /// piece, scattered through a ball of `radius` at random orientations. + static func cards(count: Int, cellsPerCard: Int, cardSize: Float, radius: Float, seed: UInt64) -> LODTestMesh { + var generator = SplitMix64(state: seed) + var mesh = LODTestMesh(positions: [], normals: [], indices: []) + for _ in 0 ..< count { + var center: SIMD3 + repeat { + center = SIMD3(generator.unit(), generator.unit(), generator.unit()) * 2 - 1 + } while simd_length(center) > 1 + center *= radius + let normal = simd_normalize(SIMD3(generator.unit(), generator.unit(), generator.unit()) * 2 - 1 + SIMD3(0, 0.01, 0)) + let helper = abs(normal.y) < 0.9 ? SIMD3(0, 1, 0) : SIMD3(1, 0, 0) + let tangent = simd_normalize(simd_cross(helper, normal)) + let bitangent = simd_cross(normal, tangent) + + let base = UInt32(mesh.positions.count) + for row in 0 ... cellsPerCard { + for column in 0 ... cellsPerCard { + let u = Float(column) / Float(cellsPerCard) - 0.5 + let v = Float(row) / Float(cellsPerCard) - 0.5 + mesh.positions.append(center + (tangent * u + bitangent * v) * cardSize) + mesh.normals.append(normal) + } + } + let stride = UInt32(cellsPerCard + 1) + for row in 0 ..< UInt32(cellsPerCard) { + for column in 0 ..< UInt32(cellsPerCard) { + let corner = base + row * stride + column + mesh.indices += [corner, corner + stride, corner + 1, corner + 1, corner + stride, corner + stride + 1] + } + } + } + return mesh + } + + static func merged(_ meshes: [LODTestMesh]) -> LODTestMesh { + var result = LODTestMesh(positions: [], normals: [], indices: [], material: meshes.first?.material ?? 0) + for mesh in meshes { + let base = UInt32(result.positions.count) + result.positions += mesh.positions + result.normals += mesh.normals + result.indices += mesh.indices.map { $0 + base } + } + return result + } +} + +private struct SplitMix64 { + var state: UInt64 + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + + /// A value in 0 ..< 1. + mutating func unit() -> Float { + Float(next() >> 40) / Float(1 << 24) + } +} + +/// A static model as the exporter cooks one: an entity and a mesh record per mesh, a +/// material per material index, no textures. +struct LODTestModel { + var meshes: [LODTestMesh] + var compressGeometry = false + /// The materials that read a base colour texture. + var texturedMaterials: Set = [] + + func write(to directory: URL, name: String) throws -> URL { + let url = directory.appendingPathComponent(name).appendingPathExtension("untold") + try data().write(to: url) + return url + } + + func data() throws -> Data { + let strings = UntoldBinaryWriter() + func string(_ value: String) -> UInt32 { + let offset = UInt32(strings.count) + strings.writeNullTerminatedUTF8(value) + return offset + } + + var vertexChunk = Data() + var indexChunk = Data() + var entities: [UntoldEntityRecordV1] = [] + var records: [UntoldMeshRecordV1] = [] + var lower = SIMD3(repeating: .greatestFiniteMagnitude) + var upper = SIMD3(repeating: -.greatestFiniteMagnitude) + for (index, mesh) in meshes.enumerated() { + let bounds = UntoldAABB( + min: mesh.positions.reduce(SIMD3(repeating: .greatestFiniteMagnitude), simd_min), + max: mesh.positions.reduce(SIMD3(repeating: -.greatestFiniteMagnitude), simd_max) + ) + lower = simd_min(lower, bounds.min) + upper = simd_max(upper, bounds.max) + + let usesShortIndices = mesh.positions.count <= 65536 + let vertexOffset = vertexChunk.count + let indexOffset = indexChunk.count + vertexChunk.append(mesh.vertexData) + let indexWriter = UntoldBinaryWriter() + for vertexIndex in mesh.indices { + if usesShortIndices { + indexWriter.writeUInt16LE(UInt16(vertexIndex)) + } else { + indexWriter.writeUInt32LE(vertexIndex) + } + } + indexChunk.append(indexWriter.data) + + entities.append(UntoldEntityRecordV1( + entityId: UInt32(index), + nameOffset: string("part_\(index)"), + firstMeshRecordIndex: UInt32(index), + meshRecordCount: 1, + localBounds: bounds, + worldBounds: bounds + )) + records.append(UntoldMeshRecordV1( + entityId: UInt32(index), + meshNameOffset: string("mesh_\(index)"), + materialIndex: mesh.material, + indexType: usesShortIndices ? .uint16 : .uint32, + vertexCount: UInt32(mesh.positions.count), + indexCount: UInt32(mesh.indices.count), + vertexStrideBytes: 32, + vertexDataOffset: UInt64(vertexOffset), + indexDataOffset: UInt64(indexOffset), + vertexDataSizeBytes: UInt64(vertexChunk.count - vertexOffset), + indexDataSizeBytes: UInt64(indexChunk.count - indexOffset), + estimatedGPUBytes: UInt64(vertexChunk.count - vertexOffset + indexChunk.count - indexOffset), + localBounds: bounds + )) + } + let materialCount = Int((meshes.map(\.material).max() ?? 0) + 1) + let materials = (0 ..< materialCount).map { index in + UntoldMaterialRecordV1( + nameOffset: string("material_\(index)"), + baseColorTextureIndex: texturedMaterials.contains(UInt32(index)) ? 0 : UntoldFormat.invalidIndex + ) + } + var textures: [UntoldTextureRefRecordV1] = [] + if !texturedMaterials.isEmpty { + let name = string("albedo.png") + textures.append(UntoldTextureRefRecordV1(nameOffset: name, uriOffset: name, textureFormat: .rgba8, width: 16, height: 16, mipCount: 1)) + } + + func table(_ values: [some UntoldBinaryEncodable]) -> Data { + let writer = UntoldBinaryWriter() + for value in values { + value.encode(to: writer) + } + return writer.data + } + /// A raw LZ4 block, as the exporter writes with --compress-geometry. + func geometry(_ bytes: Data) -> (Data, UntoldCompressionType) { + guard compressGeometry else { return (bytes, .none) } + var output = Data(count: bytes.count + 1024) + let written = output.withUnsafeMutableBytes { destination in + bytes.withUnsafeBytes { source in + compression_encode_buffer( + destination.baseAddress!.assumingMemoryBound(to: UInt8.self), + destination.count, + source.baseAddress!.assumingMemoryBound(to: UInt8.self), + source.count, + nil, + COMPRESSION_LZ4_RAW + ) + } + } + return (output.prefix(written), .lz4) + } + let (storedVertices, vertexCompression) = geometry(vertexChunk) + let (storedIndices, indexCompression) = geometry(indexChunk) + // (type, stored bytes, compression, uncompressed size, element count) + let payloads: [(UntoldChunkType, Data, UntoldCompressionType, Int, Int)] = [ + (.stringTable, strings.data, .none, strings.count, 0), + (.entityTable, table(entities), .none, table(entities).count, entities.count), + (.meshTable, table(records), .none, table(records).count, records.count), + (.materialTable, table(materials), .none, table(materials).count, materials.count), + (.textureTable, table(textures), .none, table(textures).count, textures.count), + (.vertexData, storedVertices, vertexCompression, vertexChunk.count, 0), + (.indexData, storedIndices, indexCompression, indexChunk.count, 0), + ] + + var header = UntoldFileHeaderV1( + fileType: .tile, + chunkCount: UInt32(payloads.count), + meshCount: UInt32(records.count), + materialCount: UInt32(materials.count), + textureRefCount: UInt32(textures.count), + entityCount: UInt32(entities.count), + vertexLayout: .pbrStaticV1, + worldBounds: UntoldAABB(min: lower, max: upper) + ) + let headerWriter = UntoldBinaryWriter() + header.encode(to: headerWriter) + let alignment = Int(UntoldFormat.fileAlignment) + var offset = headerWriter.count + 40 * payloads.count + var chunkEntries: [UntoldChunkEntryV1] = [] + for (chunkType, stored, compression, uncompressedSize, elementCount) in payloads { + offset += (alignment - offset % alignment) % alignment + chunkEntries.append(UntoldChunkEntryV1( + chunkType: chunkType, + compressionType: compression, + fileOffset: UInt64(offset), + compressedSize: UInt64(stored.count), + uncompressedSize: UInt64(uncompressedSize), + elementCount: UInt32(elementCount) + )) + offset += stored.count + } + + let body = UntoldBinaryWriter() + header.encode(to: body) + for entry in chunkEntries { + entry.encode(to: body) + } + for (_, stored, _, _, _) in payloads { + body.align(to: alignment) + body.writeData(stored) + } + var data = body.data + header.contentHash = try Array(UntoldFormat.contentHash(of: chunkEntries, in: data)) + let hashed = UntoldBinaryWriter() + header.encode(to: hashed) + data.replaceSubrange(0 ..< hashed.count, with: hashed.data) + return data + } +} diff --git a/Tests/UntoldEngineTests/UntoldPackTests.swift b/Tests/UntoldEngineTests/UntoldPackTests.swift index 1456057d9..3c7b63f1d 100644 --- a/Tests/UntoldEngineTests/UntoldPackTests.swift +++ b/Tests/UntoldEngineTests/UntoldPackTests.swift @@ -149,4 +149,75 @@ final class UntoldPackTests: XCTestCase { XCTAssertFalse(createUntoldScene(fromPackAt: packURL, savingTo: sceneURL)) } + + // MARK: - resolveProbedAssetURL (extension-less mesh/animation resolution) + + func testResolveProbedAssetURLPrefersPackOverUntold() throws { + try Data().write(to: tempRoot.appendingPathComponent("Foo").appendingPathExtension("untoldpack")) + try Data().write(to: tempRoot.appendingPathComponent("Foo").appendingPathExtension("untold")) + + let url = try XCTUnwrap(resolveProbedAssetURL(filename: "Foo", ext: "", probeExtensions: ["untoldpack", "untold"])) + XCTAssertEqual(url.pathExtension, "untoldpack") + } + + func testResolveProbedAssetURLFallsBackToUntoldWhenNoPackExists() throws { + try Data().write(to: tempRoot.appendingPathComponent("Foo").appendingPathExtension("untold")) + + let url = try XCTUnwrap(resolveProbedAssetURL(filename: "Foo", ext: "", probeExtensions: ["untoldpack", "untold"])) + XCTAssertEqual(url.pathExtension, "untold") + } + + func testResolveProbedAssetURLPrefersUntoldAnimOverUntoldForAnimations() throws { + try Data().write(to: tempRoot.appendingPathComponent("Walk").appendingPathExtension("untoldanim")) + try Data().write(to: tempRoot.appendingPathComponent("Walk").appendingPathExtension("untold")) + + let url = try XCTUnwrap(resolveProbedAssetURL(filename: "Walk", ext: "", probeExtensions: ["untoldanim", "untold"])) + XCTAssertEqual(url.pathExtension, "untoldanim") + } + + func testResolveProbedAssetURLHonorsExplicitExtensionOverProbing() throws { + try Data().write(to: tempRoot.appendingPathComponent("Foo").appendingPathExtension("untoldpack")) + try Data().write(to: tempRoot.appendingPathComponent("Foo").appendingPathExtension("untold")) + + // A non-empty ext (explicit withExtension, or one embedded in filename) must + // resolve exactly that file and skip probing entirely, even when a + // higher-priority candidate also exists. + let url = try XCTUnwrap(resolveProbedAssetURL(filename: "Foo", ext: "untold", probeExtensions: ["untoldpack", "untold"])) + XCTAssertEqual(url.pathExtension, "untold") + } + + func testResolveProbedAssetURLReturnsNilWhenNoCandidateExists() { + XCTAssertNil(resolveProbedAssetURL(filename: "Missing", ext: "", probeExtensions: ["untoldpack", "untold"])) + } + + func testResolveProbedAssetURLPrefersUntoldGSOverPLYForGaussianSplats() throws { + try Data().write(to: tempRoot.appendingPathComponent("Splat").appendingPathExtension("untoldgs")) + try Data().write(to: tempRoot.appendingPathComponent("Splat").appendingPathExtension("ply")) + + let url = try XCTUnwrap(resolveProbedAssetURL(filename: "Splat", ext: "", probeExtensions: ["untoldgs", "ply"])) + XCTAssertEqual(url.pathExtension, "untoldgs") + } + + func testResolveProbedAssetURLFallsBackToPLYWhenNoUntoldGSExists() throws { + try Data().write(to: tempRoot.appendingPathComponent("Splat").appendingPathExtension("ply")) + + let url = try XCTUnwrap(resolveProbedAssetURL(filename: "Splat", ext: "", probeExtensions: ["untoldgs", "ply"])) + XCTAssertEqual(url.pathExtension, "ply") + } + + func testResolveProbedAssetReturnsResolvedExtensionAlongsideURL() throws { + try Data().write(to: tempRoot.appendingPathComponent("Splat").appendingPathExtension("ply")) + + let resolved = try XCTUnwrap(resolveProbedAsset(filename: "Splat", ext: "", probeExtensions: ["untoldgs", "ply"])) + XCTAssertEqual(resolved.extension, "ply") + XCTAssertEqual(resolved.url.pathExtension, "ply") + } + + func testResolveProbedAssetHonorsExplicitExtensionWithoutProbing() throws { + try Data().write(to: tempRoot.appendingPathComponent("Splat").appendingPathExtension("untoldgs")) + try Data().write(to: tempRoot.appendingPathComponent("Splat").appendingPathExtension("ply")) + + let resolved = try XCTUnwrap(resolveProbedAsset(filename: "Splat", ext: "ply", probeExtensions: ["untoldgs", "ply"])) + XCTAssertEqual(resolved.extension, "ply") + } } diff --git a/Tools/UntoldEngineCLI/Package.swift b/Tools/UntoldEngineCLI/Package.swift index 9005a8388..856f59999 100644 --- a/Tools/UntoldEngineCLI/Package.swift +++ b/Tools/UntoldEngineCLI/Package.swift @@ -40,6 +40,7 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "UntoldEngine", package: "UntoldEngine"), + .product(name: "UntoldEngineMeshCook", package: "UntoldEngine"), ], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)] diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BakeLODsCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BakeLODsCommand.swift new file mode 100644 index 000000000..f564baa42 --- /dev/null +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BakeLODsCommand.swift @@ -0,0 +1,177 @@ +// +// BakeLODsCommand.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import ArgumentParser +import Foundation +import UntoldEngine +import UntoldEngineMeshCook + +struct BakeLODsCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "bake-lods", + abstract: "Build the automatic LOD chain of a cooked .untoldpack or .untold model", + discussion: """ + Writes simplified copies of a model next to it (_LOD1.untold, + _LOD2.untold, ...), built with meshoptimizer, one per --ratios + entry. A level the simplifier cannot reduce by a worthwhile amount is + left out, and so is the whole chain of a model under --min-triangles or + of a skinned or morphing one. + + With a .untoldpack, every distinct model of the pack gets its chain (a + model placed many times is simplified once) and the manifest records + them under "lodChains"; the engine then loads the levels with the pack + and switches between them by the size of each placement on screen. The + source asset is not needed: the chains are built from the cooked files. + + `untoldengine export` runs this step for the packs it writes, so the + command is for packs cooked earlier or with other settings. Running it + again replaces the levels of the run before. + + Each level records the screen size from which it is detailed enough: the + size at which its triangles cover --pixels-per-triangle pixels each on a + viewport 1080 pixels high. A higher value switches to the simpler levels + sooner. + + Examples: + untoldengine bake-lods --input GameData/Models/site.untoldpack + untoldengine bake-lods --input site.untoldpack --ratios 0.5,0.25,0.1,0.02 + untoldengine bake-lods --input Models/tree/tree.untold --min-triangles 500 + """ + ) + + @Option(name: .long, help: "The .untoldpack manifest or the .untold model to build chains for") + var input: String + + @Option(name: .long, help: "Share of the triangles each level aims for, finest first, comma-separated") + var ratios: String = "0.5,0.15,0.03" + + @Option(name: .customLong("min-triangles"), help: "Models with fewer triangles get no chain") + var minTriangles: Int = 2000 + + @Option(name: .customLong("pixels-per-triangle"), help: "Screen pixels a triangle of a level covers when the level takes over (at 1080 lines)") + var pixelsPerTriangle: Float = 4 + + func run() throws { + let inputURL = resolvePath(input).standardizedFileURL + guard FileManager.default.fileExists(atPath: inputURL.path) else { + throw BakeLODsError.inputNotFound(inputURL.path) + } + let options = try UntoldMeshLODOptions( + ratios: Self.parseRatios(ratios), + minimumTriangles: minTriangles, + pixelsPerTriangle: pixelsPerTriangle + ) + + switch inputURL.pathExtension.lowercased() { + case "untoldpack": + let report = try Self.bakePack(at: inputURL, options: options) + Self.printReport(report, packURL: inputURL) + case "untold": + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: inputURL, options: options) + Self.printChain(chain, modelURL: inputURL) + default: + throw BakeLODsError.unsupportedInput(inputURL.pathExtension) + } + } + + static func parseRatios(_ text: String) throws -> [Float] { + let values = text.split(separator: ",", omittingEmptySubsequences: false).map { Float($0.trimmingCharacters(in: .whitespaces)) } + guard !values.isEmpty, !values.contains(nil) else { + throw BakeLODsError.invalidRatios(text) + } + return values.compactMap { $0 } + } + + /// Cooks the chains of a pack with a progress line on stderr. + static func bakePack(at packURL: URL, options: UntoldMeshLODOptions) throws -> UntoldPackLODReport { + printInfo("Building LOD chains for \(packURL.lastPathComponent)") + let isTerminal = isatty(fileno(stderr)) != 0 + let report = try UntoldMeshLODCooker.cookChains(forPackAt: packURL, options: options) { done, total in + // One updating line on a terminal; a line every tenth of the way otherwise. + if isTerminal { + FileHandle.standardError.write(Data("\r \(done) / \(total) models".utf8)) + } else if done == total || done % max(total / 10, 1) == 0 { + FileHandle.standardError.write(Data(" \(done) / \(total) models\n".utf8)) + } + } + if isTerminal { + FileHandle.standardError.write(Data("\n".utf8)) + } + return report + } + + static func printReport(_ report: UntoldPackLODReport, packURL: URL) { + for failure in report.failures { + printWarning("No LOD chain for \(failure.path): \(failure.reason)") + } + for path in report.blockedPaths { + printWarning("No LOD chain for \(path): a file that is not one of its levels already has a level's name (_LOD)") + } + guard report.chainCount > 0 else { + printInfo("No model of \(packURL.lastPathComponent) needed a LOD chain (\(report.modelCount) model(s))") + return + } + let megabytes = String(format: "%.1f MB", Double(report.levelBytes) / 1_048_576) + printSuccess( + "LOD chains: \(report.chainCount) of \(report.modelCount) model(s), \(report.levelCount) level file(s), \(megabytes)" + ) + printInfo( + " \(grouped(report.chainedTriangleCount)) triangles in those models, \(grouped(report.coarsestTriangleCount)) at their coarsest level" + ) + } + + static func printChain(_ chain: UntoldMeshLODChain, modelURL: URL) { + if let reason = chain.skipped { + printInfo("No LOD chain for \(modelURL.lastPathComponent) (\(grouped(chain.triangleCount)) triangles): \(describe(reason))") + return + } + printSuccess("LOD chain for \(modelURL.lastPathComponent) (\(grouped(chain.triangleCount)) triangles)") + for level in chain.levels { + let share = String(format: "%.1f %%", 100 * Double(level.triangleCount) / Double(max(chain.triangleCount, 1))) + printInfo( + " \(level.url.lastPathComponent): \(grouped(level.triangleCount)) triangles (\(share)), from screen size \(String(format: "%.3f", level.screenSize))" + ) + } + } + + private static func describe(_ reason: UntoldMeshLODChain.SkipReason) -> String { + switch reason { + case .belowMinimumTriangles: "fewer triangles than --min-triangles" + case .deforms: "skinned, morphing or animation-only models keep their full meshes" + case .gaussianTwin: "the mesh twin of a Gaussian splat swaps to its splat" + case .isLevel: "the file is a level of another model" + case .levelNameTaken: "a file that is not one of its levels already has a level's name (_LOD)" + case .irreducible: "the simplifier could not reduce it by a worthwhile amount" + } + } + + private static func grouped(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } +} + +enum BakeLODsError: LocalizedError, Equatable { + case inputNotFound(String) + case unsupportedInput(String) + case invalidRatios(String) + + var errorDescription: String? { + switch self { + case let .inputNotFound(path): + "Input not found: \(path)" + case let .unsupportedInput(pathExtension): + "bake-lods needs a .untoldpack or .untold input, not .\(pathExtension)" + case let .invalidRatios(text): + "--ratios must be comma-separated numbers such as 0.5,0.15,0.03 (got \"\(text)\")" + } + } +} diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift index 154f871c1..ace28e73b 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift @@ -12,6 +12,7 @@ import ArgumentParser import Foundation import simd import UntoldEngine +import UntoldEngineMeshCook struct ExportCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -44,9 +45,12 @@ struct ExportCommand: ParsableCommand { If the source .blend scene contains more than one independent model (more than one object with no parent among the exported objects), the exporter writes a .untoldpack manifest next to --output instead - of a single .untold file, plus one self-contained .untold per model - under its own subfolder. --optimize bakes textures for every model - in the pack. + of a single .untold file, plus one .untold per model under its own + subfolder; copies of a model share one file, and the models share one + Textures folder. --optimize bakes those textures for the whole pack. + Each model of a pack that is detailed enough also gets its LOD chain + (simplified copies the engine switches to with distance, see + `untoldengine bake-lods`); --no-lods leaves them out. Example: untoldengine export --input model.usdz --output model.untold --convert-orientation --optimize @@ -99,6 +103,9 @@ struct ExportCommand: ParsableCommand { @Flag(name: .long, help: "Compress geometry and bake/patch textures after export (implies --compress-geometry)") var optimize = false + @Flag(name: .customLong("no-lods"), help: "Do not build the LOD chains of a pack's models (see `untoldengine bake-lods`)") + var noLODs = false + @Option(name: .customLong("color-grade-lut"), help: "Path to an externally-authored standard .cube 3D LUT to stage and apply as a post-tonemap creative grade. Nothing is rendered from Blender -- the .cube is copied as-is and loaded directly by the engine") var colorGradeLUT: String? @@ -272,11 +279,27 @@ struct ExportCommand: ParsableCommand { printInfo(" \(model.displayName ?? model.path) -> \(model.path)") } if optimize { + // Several placements can share one .untold, so each file is handled once. + var modelURLs: [URL] = [] for model in pack.models { - let modelURL = packURL.deletingLastPathComponent().appendingPathComponent(model.path) - try optimizeTextures(outputURL: modelURL) + let modelURL = packURL.deletingLastPathComponent().appendingPathComponent(model.path).standardizedFileURL + if !modelURLs.contains(modelURL) { modelURLs.append(modelURL) } + } + let sharedTextures = (assetsURL ?? packURL.deletingLastPathComponent()).appendingPathComponent("Textures") + if validateDirectory(sharedTextures) { + try optimizeSharedTextures(texturesDir: sharedTextures, packURL: packURL, modelURLs: modelURLs) + } else { + // An exporter that predates the shared Textures/ folder gives each model its own. + for modelURL in modelURLs { + try optimizeTextures(outputURL: modelURL) + } } } + + // Last, so that the levels copy the materials the models end up with. + if !noLODs { + bakeLODChains(packURL: packURL) + } } else { // Mirror image of the above: an old pack manifest from a previous // multi-model export at this stem may still be sitting here, left by @@ -304,6 +327,17 @@ struct ExportCommand: ParsableCommand { } } + /// Builds the LOD chain of every model of the pack, as `untoldengine bake-lods` does. + /// The pack is complete without them, so a failure is reported and the export stands. + private func bakeLODChains(packURL: URL) { + do { + let report = try BakeLODsCommand.bakePack(at: packURL, options: UntoldMeshLODOptions()) + BakeLODsCommand.printReport(report, packURL: packURL) + } catch { + printWarning("The LOD chains of \(packURL.lastPathComponent) were not built: \(error)") + } + } + private func modificationDate(at url: URL) -> Date? { (try? FileManager.default.attributesOfItem(atPath: url.path))?[.modificationDate] as? Date } @@ -517,6 +551,26 @@ struct ExportCommand: ParsableCommand { printSuccess("Optimized textures: \(texturesDir.path)") } + /// A pack's Textures/ folder, shared by its models: baked once, with the pack's + /// model files read for slot hints, then each model's references patched. + private func optimizeSharedTextures(texturesDir: URL, packURL: URL, modelURLs: [URL]) throws { + let python3URL = try resolvePython3() + let texbakeScriptURL = try resolveTexbakeScript() + + printInfo("Baking textures: \(texturesDir.path)") + // The pack's manifest, from which texbake takes the models it lists: naming each + // file would overflow the command line on a large pack, and naming their folder + // would bring in whatever else an assets folder holds. + try runPython(python3URL, [texbakeScriptURL.path, "--dir", texturesDir.path, "--untold", packURL.path]) + + for modelURL in modelURLs { + printInfo("Patching texture references: \(modelURL.path)") + try runPython(python3URL, [texbakeScriptURL.path, "--patch-refs", modelURL.path]) + } + + printSuccess("Optimized textures: \(texturesDir.path)") + } + private func runPython(_ python3URL: URL, _ arguments: [String]) throws { let process = Process() process.executableURL = python3URL diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift index 7267b08a7..0f52a0b97 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift @@ -16,6 +16,6 @@ struct UntoldEngineCLI: AsyncParsableCommand { commandName: "untoldengine", abstract: "UntoldEngine command-line tools", version: "0.1.0", - subcommands: [CreateCommand.self, UpdateCommand.self, AssetsCommand.self, ExportCommand.self, ExportTilesCommand.self, GaussianLinkCommand.self, TexbakeCommand.self, BakeMLDeformerCommand.self, BootstrapCommand.self, BlenderAddonCommand.self, StudioCommand.self] + subcommands: [CreateCommand.self, UpdateCommand.self, AssetsCommand.self, ExportCommand.self, ExportTilesCommand.self, GaussianLinkCommand.self, TexbakeCommand.self, BakeLODsCommand.self, BakeMLDeformerCommand.self, BootstrapCommand.self, BlenderAddonCommand.self, StudioCommand.self] ) } diff --git a/Tools/UntoldEngineCLI/Tests/UntoldEngineCLITests/BakeLODsCommandTests.swift b/Tools/UntoldEngineCLI/Tests/UntoldEngineCLITests/BakeLODsCommandTests.swift new file mode 100644 index 000000000..752594c98 --- /dev/null +++ b/Tools/UntoldEngineCLI/Tests/UntoldEngineCLITests/BakeLODsCommandTests.swift @@ -0,0 +1,124 @@ +// +// BakeLODsCommandTests.swift +// UntoldEngineCLI +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import ArgumentParser +import Foundation +import UntoldEngine +@testable import UntoldEngineCLI +import UntoldEngineMeshCook +import XCTest + +final class BakeLODsCommandTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("BakeLODsCommandTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + if let directory { + try? FileManager.default.removeItem(at: directory) + } + super.tearDown() + } + + /// A cooked model of the engine's render tests: seven meshes, 2,264 triangles. + private func copyStadium(to url: URL) throws { + let source = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // UntoldEngineCLITests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // UntoldEngineCLI + .deletingLastPathComponent() // Tools + .deletingLastPathComponent() // repository root + .appendingPathComponent("Tests/UntoldEngineRenderTests/Resources/Models/stadium/stadium.untold") + guard FileManager.default.fileExists(atPath: source.path) else { + throw XCTSkip("cooked fixture not found at \(source.path)") + } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.copyItem(at: source, to: url) + } + + func testRatiosAreReadFromACommaSeparatedList() throws { + XCTAssertEqual(try BakeLODsCommand.parseRatios("0.5,0.15,0.03"), [0.5, 0.15, 0.03]) + XCTAssertEqual(try BakeLODsCommand.parseRatios(" 0.25 , 0.05 "), [0.25, 0.05]) + for text in ["", "half", "0.5,,0.1", "0.5;0.1"] { + XCTAssertThrowsError(try BakeLODsCommand.parseRatios(text), text) { error in + XCTAssertEqual(error as? BakeLODsError, .invalidRatios(text)) + } + } + } + + func testTheDefaultsAreTheCooksDefaults() throws { + let command = try BakeLODsCommand.parse(["--input", "site.untoldpack"]) + let defaults = UntoldMeshLODOptions() + + XCTAssertEqual(try BakeLODsCommand.parseRatios(command.ratios), defaults.ratios) + XCTAssertEqual(command.minTriangles, defaults.minimumTriangles) + XCTAssertEqual(command.pixelsPerTriangle, defaults.pixelsPerTriangle) + } + + func testAModelGetsItsLevelsNextToIt() throws { + let modelURL = directory.appendingPathComponent("stadium.untold") + try copyStadium(to: modelURL) + + let command = try BakeLODsCommand.parse(["--input", modelURL.path]) + try command.run() + + for level in 1 ... 3 { + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("stadium_LOD\(level).untold").path)) + } + } + + func testAPackGetsItsChainsInTheManifest() throws { + try copyStadium(to: directory.appendingPathComponent("Site/Stadium/Stadium.untold")) + let packURL = directory.appendingPathComponent("Site.untoldpack") + let manifest = """ + {"formatVersion": 1, "sourceAsset": "Site.blend", "models": [ + {"displayName": "Stadium", "path": "Site/Stadium/Stadium.untold", + "transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]} + ]} + """ + try Data(manifest.utf8).write(to: packURL) + + let command = try BakeLODsCommand.parse(["--input", packURL.path, "--ratios", "0.5,0.1"]) + try command.run() + + let chain = try XCTUnwrap(loadUntoldPack(url: packURL)?.lodChains?["Site/Stadium/Stadium.untold"]) + XCTAssertEqual(chain.map(\.path), ["Site/Stadium/Stadium_LOD1.untold", "Site/Stadium/Stadium_LOD2.untold"]) + } + + func testOtherInputsAreRefused() throws { + let missing = try BakeLODsCommand.parse(["--input", directory.appendingPathComponent("nothing.untoldpack").path]) + XCTAssertThrowsError(try missing.run()) { error in + guard case BakeLODsError.inputNotFound = error else { return XCTFail("unexpected error \(error)") } + } + + let sourceURL = directory.appendingPathComponent("model.blend") + try Data().write(to: sourceURL) + let source = try BakeLODsCommand.parse(["--input", sourceURL.path]) + XCTAssertThrowsError(try source.run()) { error in + XCTAssertEqual(error as? BakeLODsError, .unsupportedInput("blend")) + } + + let modelURL = directory.appendingPathComponent("stadium.untold") + try copyStadium(to: modelURL) + let decreasing = try BakeLODsCommand.parse(["--input", modelURL.path, "--ratios", "0.1,0.5"]) + XCTAssertThrowsError(try decreasing.run()) { error in + guard case UntoldMeshLODError.invalidOptions = error else { return XCTFail("unexpected error \(error)") } + } + } + + func testExportBuildsTheChainsOfAPackUnlessAskedNotTo() throws { + XCTAssertFalse(try ExportCommand.parse(["--input", "site.blend", "--output", "site.untold"]).noLODs) + XCTAssertTrue(try ExportCommand.parse(["--input", "site.blend", "--output", "site.untold", "--no-lods"]).noLODs) + } +} diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index 4897912b1..000683098 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -27,7 +27,7 @@ Clone the repository and launch the demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.21.0 +git checkout v0.22.0 swift run ShowcaseDemo ``` @@ -105,13 +105,21 @@ Use `setEntityMeshAsync` to load an `.untold` file as an always-resident asset. This is the right choice for props, characters, and any object that should stay in memory for the lifetime of the scene. +> `setEntityMeshAsync` here works whether `Bedroom` exported as a single +> `.untold` or, for a `.blend` scene with more than one independent model, a +> `.untoldpack` — omitting `withExtension` probes for either. `loadSceneAuthored` +> below is different: it only ever resolves a single `.untold` and has no +> effect on a `.untoldpack`, since a pack has no scene-level slot for the +> lights/cameras/color-grade it would bring in — see [Using the Untold Engine +> CLI](UsingUntoldEngineCLI.md#multi-model-blend-scenes--untoldpack). + ```swift //...After configureEngineSystems() let entity = createEntity() -setEntityMeshAsync(entityId: entity, filename: "Bedroom", withExtension: "untold"){ success in +setEntityMeshAsync(entityId: entity, filename: "Bedroom"){ success in if success { // Load blender authored color management diff --git a/docs/API/UsageExamples.md b/docs/API/UsageExamples.md index c962b8dc0..859355972 100644 --- a/docs/API/UsageExamples.md +++ b/docs/API/UsageExamples.md @@ -28,7 +28,7 @@ setup. let crate = createEntity() setEntityName(entityId: crate, name: "crate") -setEntityMesh(entityId: crate, filename: "crate", withExtension: "untold") +setEntityMesh(entityId: crate, filename: "crate") translateTo(entityId: crate, position: simd_float3(0.0, 0.0, 0.0)) ``` @@ -48,7 +48,7 @@ setSceneReady(false) let robot = createEntity() setEntityName(entityId: robot, name: "robot") -setEntityMeshAsync(entityId: robot, filename: "robot", withExtension: "untold") { success in +setEntityMeshAsync(entityId: robot, filename: "robot") { success in if success { translateTo(entityId: robot, position: simd_float3(0.0, 0.0, 0.0)) rotateTo(entityId: robot, angle: 0.0, axis: simd_float3(0.0, 1.0, 0.0)) @@ -95,13 +95,18 @@ data, load that data separately from the mesh. `setEntityMesh` and the Blender-authored lights/cameras and installs any `.cube` creative grade LUT staged with `--color-grade-lut`. +`loadSceneAuthored` always needs `withExtension: "untold"` explicitly — unlike +the mesh load above it, it never probes for `.untoldpack`, since a pack has no +scene-level slot for this data in the first place (see [Using the Untold +Engine CLI](UsingUntoldEngineCLI.md#multi-model-blend-scenes--untoldpack)). + ```swift setSceneReady(false) let office = createEntity() setEntityName(entityId: office, name: "office") -setEntityMeshAsync(entityId: office, filename: "office", withExtension: "untold") { meshLoaded in +setEntityMeshAsync(entityId: office, filename: "office") { meshLoaded in guard meshLoaded else { setSceneReady(false) return @@ -182,14 +187,14 @@ same entity. Use `changeAnimation` to choose the active clip. let player = createEntity() setEntityName(entityId: player, name: "player") -setEntityMeshAsync(entityId: player, filename: "redplayer", withExtension: "untold") { success in +setEntityMeshAsync(entityId: player, filename: "redplayer") { success in guard success else { setSceneReady(false) return } - setEntityAnimations(entityId: player, filename: "running", withExtension: "untold", name: "running") - setEntityAnimations(entityId: player, filename: "idle", withExtension: "untold", name: "idle") + setEntityAnimations(entityId: player, filename: "running", name: "running") + setEntityAnimations(entityId: player, filename: "idle", name: "idle") changeAnimation(entityId: player, name: "idle") setSceneReady(true) @@ -221,7 +226,7 @@ gravity, or forces as needed. let ball = createEntity() setEntityName(entityId: ball, name: "ball") -setEntityMeshAsync(entityId: ball, filename: "ball", withExtension: "untold") { success in +setEntityMeshAsync(entityId: ball, filename: "ball") { success in guard success else { setSceneReady(false) return @@ -322,11 +327,11 @@ final class GameScene { let player = createEntity() setEntityName(entityId: player, name: "player") - setEntityMeshAsync(entityId: player, filename: "redplayer", withExtension: "untold") { success in + setEntityMeshAsync(entityId: player, filename: "redplayer") { success in if success { translateTo(entityId: player, position: simd_float3(0.0, 0.0, 0.0)) - setEntityAnimations(entityId: player, filename: "idle", withExtension: "untold", name: "idle") - setEntityAnimations(entityId: player, filename: "running", withExtension: "untold", name: "running") + setEntityAnimations(entityId: player, filename: "idle", name: "idle") + setEntityAnimations(entityId: player, filename: "running", name: "running") changeAnimation(entityId: player, name: "idle") setEntityKinetics(entityId: player) } @@ -339,7 +344,7 @@ final class GameScene { let ball = createEntity() setEntityName(entityId: ball, name: "ball") - setEntityMeshAsync(entityId: ball, filename: "ball", withExtension: "untold") { success in + setEntityMeshAsync(entityId: ball, filename: "ball") { success in if success { translateTo(entityId: ball, position: simd_float3(2.0, 1.0, 0.0)) setEntityKinetics(entityId: ball) diff --git a/docs/API/UsingEngineAPI.md b/docs/API/UsingEngineAPI.md index 190e8b3ec..d31a038e4 100644 --- a/docs/API/UsingEngineAPI.md +++ b/docs/API/UsingEngineAPI.md @@ -60,6 +60,18 @@ setRendering(.maxShadowCastingDistance(2.0)) getMaxShadowCastingDistance() ``` +Objects too small on screen to be seen are left out of the frame: by default, any object whose +bounds are under one pixel tall is not drawn and casts no shadow. A scene with tens of thousands +of small parts (a building model with every clip and bolt) shows most of them at a pixel or less +from a distance, and each would still cost a draw. The size is that of the sphere around the +object's bounds, so a long thin object goes only once its whole length is that small. Raise the +size to drop more, or set it to 0 to draw everything: + +```swift +setRendering(.smallObjectCulling(pixels: 2.0)) +getSmallObjectCullingPixels() +``` + ## PostFX Use `setPostFX` for individual post-processing and SSAO settings: diff --git a/docs/API/UsingGaussianSystem.md b/docs/API/UsingGaussianSystem.md index c673f7c3b..054ea9453 100644 --- a/docs/API/UsingGaussianSystem.md +++ b/docs/API/UsingGaussianSystem.md @@ -43,6 +43,23 @@ A baked `.untoldgs` file (see [Exporting Assets](UsingUntoldEngineCLI.md#gaussia loads the same way and is the faster path: its chunks are read by byte range and decoded on the GPU, so nothing is parsed on the CPU. +**Omitting `withExtension` is the recommended default.** `setEntityGaussian`, +`setEntityGaussianAsync`, and `.single(filename:)` on `GaussianSource` all probe +for `.untoldgs` first, then `.ply`, the same convention `setEntityMeshAsync` +uses for `.untoldpack`/`.untold` (see [Using the Registration +System](UsingRegistrationSystem.md)): + +```swift +// Resolves "splat.untoldgs" if a baked capture exists, otherwise "splat.ply" — +// the caller doesn't need to know which one is on disk. +setEntityGaussian(entityId: myEntity, filename: "splat") +setEntityGaussianAsync(entityId: myEntity, filename: "splat") { success in ... } +setEntityGaussian(entityId: myEntity, source: .single(filename: "splat")) +``` + +Pass `withExtension` explicitly only to force one format regardless of what +else exists at that name. + ```swift setEntityGaussian(entityId: myEntity, filename: "splat", withExtension: "untoldgs") ``` @@ -60,27 +77,11 @@ parsing, per-splat encoding, and spherical-harmonics packing all run off the mai only the final component registration touches the world. Use it for a one-off splat load where you don't want a frame hitch but don't need distance-based streaming. -```swift -Task { - let ok = await setEntityGaussianAsync( - entityId: myEntity, - filename: "splat", - withExtension: "ply" - ) - if !ok { - print("Failed to load splat") - } -} -``` - -`completion` is an optional alternative to checking the returned `Bool`: +Call it directly — no `Task`/`await` needed at the call site — and read the +result via the optional `completion`: ```swift -await setEntityGaussianAsync( - entityId: myEntity, - filename: "splat", - withExtension: "ply" -) { success in +setEntityGaussianAsync(entityId: myEntity, filename: "splat") { success in print(success ? "Loaded" : "Failed to load splat") } ``` @@ -710,7 +711,9 @@ setEntityGaussianTileStreaming( `GaussianSource` selects what kind of Gaussian asset the streaming system should load: ```swift -.single(filename: String, withExtension: String) +// withExtension defaults to "" (probe .untoldgs then .ply); pass one explicitly +// to force a specific format. +.single(filename: String, withExtension: String = "") .progressive( baseFilename: String, @@ -768,9 +771,11 @@ setEntityGaussianTileStreaming( Parameters: - `entityId`: The entity created and positioned in Step 1. -- `source`: Use `.single(filename:withExtension:)` for a whole `.ply`/`.untoldgs` asset, or +- `source`: Use `.single(filename:)` for a whole `.ply`/`.untoldgs` asset — omit + `withExtension` to probe `.untoldgs` then `.ply`, or pass it explicitly to force one — or `.progressive(baseFilename:levelCount:maxDistances:)` for progressive tiers named - `_lod0.untoldgs`, `_lod1.untoldgs`, etc. + `_lod0.untoldgs`, `_lod1.untoldgs`, etc. (always `.untoldgs`; + there is no progressive `.ply`). - `streamingRadius`: Distance from the camera at which the splat starts loading. - `unloadRadius`: Distance beyond which the splat unloads. Should be larger than `streamingRadius` to avoid load/unload thrashing at the boundary. diff --git a/docs/API/UsingGeometryStreamingSystem.md b/docs/API/UsingGeometryStreamingSystem.md index 237e54c37..e393e0bcd 100644 --- a/docs/API/UsingGeometryStreamingSystem.md +++ b/docs/API/UsingGeometryStreamingSystem.md @@ -44,6 +44,24 @@ if let url = URL(string: "https://cdn.example.com/city/city.json") { Remote manifests are downloaded and cached locally. Tile, HLOD, and per-tile LOD URLs are resolved relative to the manifest URL and fetched on demand. +### The root entity + +The root is your entity. Tile stubs are parented under it, and destroying it tears the streamed scene down. + +- **It keeps its place in the scene graph.** A root that has a parent stays under it, and entities already parented under the root stay there. +- **Its own position, rotation and scale are reset to identity** when the stubs are registered. Tile bounds in the manifest are world-space values and do not follow the root. A warning is logged when this discards a transform. +- **Its ancestors should be at identity too.** If they are not, the scene is drawn where they put it while streaming and culling still use the manifest bounds: tiles can fail to load or be culled wrongly. A warning says so. + +To place, turn or scale a streamed scene, use the scene-root functions, which the streaming system accounts for: + +```swift +translateSceneTo(position: simd_float3(0.0, 0.0, -2.0)) +rotateSceneToYaw(.pi / 2.0) +scaleSceneTo(0.1) +``` + +See [Using the Transform System](UsingTransformSystem.md) for these functions. + ## What Streams The engine uses multiple geometry layers: diff --git a/docs/API/UsingInputSystem.md b/docs/API/UsingInputSystem.md index c588ff0e7..741c8b62f 100644 --- a/docs/API/UsingInputSystem.md +++ b/docs/API/UsingInputSystem.md @@ -56,7 +56,7 @@ if keyState.dPressed { |---|---| | Movement/common letters | `wPressed`, `aPressed`, `sPressed`, `dPressed`, `qPressed`, `ePressed`, `fPressed`, `hPressed`, `jPressed`, `kPressed`, `lPressed` | | Function keys | `f1Pressed` through `f12Pressed` | -| Navigation/modifier keys | `tabPressed`, `spacePressed`, `shiftPressed`, `ctrlPressed`, `altPressed` | +| Navigation/modifier keys | `tabPressed`, `spacePressed`, `shiftPressed`, `ctrlPressed`, `altPressed`, `commandPressed` | | Mouse buttons | `leftMousePressed`, `rightMousePressed`, `middleMousePressed` | On macOS, keyboard events are ignored while an `NSText` field is focused, so typing into editor text controls does not leak into game input. Modifier flags update from system flag-change events. diff --git a/docs/API/UsingLODSystem.md b/docs/API/UsingLODSystem.md index 1cc05dbab..c346fecb8 100644 --- a/docs/API/UsingLODSystem.md +++ b/docs/API/UsingLODSystem.md @@ -206,6 +206,76 @@ print("Entity has \(count) LOD levels") --- +## Automatic LOD Chains for Packs + +A `.untoldpack` can carry a LOD chain for each of its models, so a scene exported +from Blender gets its levels without anybody authoring them. `untoldengine +export` builds the chains when it writes a pack, and `untoldengine bake-lods` +builds them for a pack that is already cooked (see [Using the UntoldEngine +CLI](UsingUntoldEngineCLI.md#lod-chains-for-packs)). + +Nothing changes in how the pack is loaded: + +```swift +let site = createEntity() +setEntityMeshAsync(entityId: site, filename: "site", withExtension: "untoldpack") +``` + +For every model that has a chain, each entity the model creates gets an +`LODComponent` whose level 0 is the model itself, followed by the levels of the +chain: + +- **The levels are built once.** Placements of the same model share the GPU + buffers of every level, as they share those of the model. +- **Each placement switches at its own distances.** The manifest gives every + level a screen size: the share of the viewport height the model's bounding + sphere covers when the level becomes detailed enough. On load that size becomes + a distance for the placement, from the model's bounds, the placement's scale and + the engine's field of view. A small prop and a large tree therefore both switch + where their triangles are a few pixels each, although their distances differ + by orders of magnitude. The distances are computed when the pack loads; scaling + a placement afterwards does not move them. +- **The materials belong to the entity.** The levels are drawn with the model's + materials, and a material that is edited or streamed while one level is on + screen stays when another takes its place (`LODComponent.levelsShareMaterials`). + +`setLOD(.distanceBias(...))` moves all switch distances together, and +`forcedLOD` pins a level, as for any other LOD entity. + +The manifest records the chains under `lodChains`, by model path: + +```json +"lodChains": { + "site/Tree/Tree.untold": [ + { "path": "site/Tree/Tree_LOD1.untold", "triangles": 926291, "screenSize": 1.7823, "error": 0.037319 }, + { "path": "site/Tree/Tree_LOD2.untold", "triangles": 299523, "screenSize": 1.0135, "error": 0.068814 }, + { "path": "site/Tree/Tree_LOD3.untold", "triangles": 83611, "screenSize": 0.53547, "error": 0.155 } + ] +} +``` + +`screenSize` is measured on a viewport 1080 pixels high, and `error` is the +largest deviation from the model in model units. + +To build chains from your own tools, add the `UntoldEngineMeshCook` product of the +engine package to the tool and call the cooker: + +```swift +// Package.swift of the tool +.product(name: "UntoldEngineMeshCook", package: "UntoldEngine") +``` + +```swift +import UntoldEngineMeshCook + +let report = try UntoldMeshLODCooker.cookChains(forPackAt: packURL) +``` + +The cook is a module of its own because it brings a C++ mesh simplifier +(meshoptimizer) that an app which only loads the pack does not need. + +--- + ## Advanced Usage ### Custom Distance Thresholds @@ -232,7 +302,9 @@ Configure global LOD behavior: setLOD(.distanceBias(1.5)) // Performance mode setLOD(.distanceBias(0.75)) // Quality mode -// Adjust hysteresis to prevent flickering +// Adjust hysteresis to prevent flickering. It never takes more than a tenth of +// a level's own switch distance, so levels that switch close to the camera +// still switch back. setLOD(.hysteresis(10.0)) // Enable dithered cross-fade transitions between LOD representations diff --git a/docs/API/UsingLightingSystem.md b/docs/API/UsingLightingSystem.md index c3d40463e..4f739e2c1 100644 --- a/docs/API/UsingLightingSystem.md +++ b/docs/API/UsingLightingSystem.md @@ -62,6 +62,20 @@ let panel = createEntity() createAreaLight(entityId: panel) ``` +### A Light on an Entity That Is Already Placed + +A light takes the transform of the entity it is created on. The entity keeps its position, its scale, its parent and its children, so it can be placed before or after it becomes a light: + +```swift +let lamp = createEntity() +setParent(childId: lamp, parentId: room) +translateTo(entityId: lamp, position: simd_float3(0, 2.5, 0)) + +createPointLight(entityId: lamp) // stays at (0, 2.5, 0) under `room` +``` + +An entity that has not been rotated gets the default orientation of the light: a sun straight overhead for a directional light, pointing down for the others. An entity that was rotated before keeps its rotation, and the light emits along its local `-Z`. + --- ## Configuring Light Properties diff --git a/docs/API/UsingMaterials.md b/docs/API/UsingMaterials.md index 2852557a3..bde170f91 100644 --- a/docs/API/UsingMaterials.md +++ b/docs/API/UsingMaterials.md @@ -91,6 +91,10 @@ To apply the same emissive value to an entity and all of its scenegraph descenda updateMaterialEmmisive(entityId: rootEntity, emmissive: simd_float3(1.0, 0.5, 0.0), recursive: true) ``` +> When the material has an emissive texture, the value multiplies it: the texture gives the color of the glow and the value its strength. A material loaded with an emissive texture and no value of its own starts at white, so the texture shows as painted. + +> On a material with the blend alpha mode the glow fades with the surface: at half opacity it gives off half the light. + > **Spelling note:** The API currently uses `getMaterialEmmissive` / `updateMaterialEmmisive` (with double-m). Use these exact names when calling the functions. --- diff --git a/docs/API/UsingPoseLayers.md b/docs/API/UsingPoseLayers.md index cc3d03d92..8276c065a 100644 --- a/docs/API/UsingPoseLayers.md +++ b/docs/API/UsingPoseLayers.md @@ -61,6 +61,34 @@ setReachIKTarget(entityId: zombie, worldPosition: nil) setReachIKChainWeights(entityId: zombie, weights: [0.9, 0.2]) ``` + When the hands follow tracking instead of sharing one goal, give each + chain its own target, index-aligned with the chains: + +```swift +setReachIKChainTargets(entityId: character, targets: [ + ReachIKChainTarget(position: leftOffset, space: .shoulder), + ReachIKChainTarget(position: rightOffset, space: .shoulder), +], halflife: 0.2, targetHalflife: 0.03) +``` + + A target is a world position, a model-space position, an offset from + the chain's own shoulder along the model axes (the hand keeps its place + relative to the body however the body moves that frame), or a spot on + the model's ground whose height is ignored: a leg chain (hip, knee, + ankle) holds a foot where it stands while the pose lifts and lowers it. + A chain whose entry is nil reaches for the shared target, or keeps its + pose when there is none. `targetHalflife` is the easing of the chains' + own targets: short for a source that is already filtered. It leaves + the shared target's easing alone. + + The two calls can be mixed (tracked hands on some chains, a shared + grab target for the rest), with one thing to keep in mind: an entity + has a single reach influence. `weight`, `halflife` and `reach` belong + to the entity, not to the call, so whichever of `setReachIKTarget` and + `setReachIKChainTargets` runs last in a frame sets them for every + chain. Pass the same values to both; per-chain differences go through + `setReachIKChainWeights`. + ## What Happens Behind the Scenes Every frame, after the base clip is sampled, root motion extracted and diff --git a/docs/API/UsingPostFX.md b/docs/API/UsingPostFX.md index 154528cc3..a01a06fd2 100644 --- a/docs/API/UsingPostFX.md +++ b/docs/API/UsingPostFX.md @@ -96,7 +96,7 @@ setRendering(.antiAliasing(.none)) // No anti-aliasing | `.smaa` | Three-pass chain (edge detection → blend weights → neighborhood blend). Sharper than FXAA, handles diagonal and corner patterns. Costs ~3Ɨ the GPU time of FXAA. | | `.none` | Anti-aliasing skipped entirely. The output transform reads directly from the look pass. | -SMAA also exposes intermediate debug views through `setRendering(.debugView(...))`: +SMAA also exposes intermediate debug views through `setRendering(.debugView(...))`, alongside G-Buffer, occlusion, tonemap, and POM debug views: ```swift setRendering(.debugView(.smaaEdges)) // Show edge detection result @@ -104,8 +104,18 @@ setRendering(.debugView(.smaaBlend)) // Show blend-weight texture setRendering(.debugView(.smaaDifference)) // Show original vs. resolved difference setRendering(.debugView(.fxaaEdgeDebug)) // Show FXAA luma-gradient edge map setRendering(.debugView(.lit)) // Normal rendering (default) + +setRendering(.debugView(.roughness)) // G-Buffer roughness channel +setRendering(.debugView(.metallic)) // G-Buffer metallic channel +setRendering(.debugView(.occlusionDebug)) // Green wireframe AABBs around HZB-occluded entities +setRendering(.debugView(.preTonemapHDRLuminance)) // Scene luminance before Look/ACES tonemapping +setRendering(.debugView(.postTonemapOutput)) // Post-tonemap output, routed explicitly for color-pipeline checks +setRendering(.debugView(.heightDebug)) // Raw height-map sample used by Parallax Occlusion Mapping +setRendering(.debugView(.pomOffsetDebug)) // Magnitude of the POM UV displacement, as a heatmap ``` +See [Using Rendering System](UsingRenderingSystem.md#debug-view-modes) for the full `RenderDebugViewMode` list. + --- ## Enabling Individual Effects diff --git a/docs/API/UsingRegistrationSystem.md b/docs/API/UsingRegistrationSystem.md index 36149ebf7..016393d70 100644 --- a/docs/API/UsingRegistrationSystem.md +++ b/docs/API/UsingRegistrationSystem.md @@ -52,6 +52,31 @@ setEntityMeshAsync(entityId: entity, filename: "model.untold") { success in ... Passing `withExtension` explicitly (as above) still works exactly as before and takes priority if both are given; this applies to every `filename`/`withExtension` pair in the engine (`setEntityMesh`, `setEntityMeshAsync`, `setEntityAnimations`, `setEntityGaussian`, `loadSceneAuthored`, `setColorGradeLUT`). +Leaving `withExtension` out entirely (and not embedding one in `filename` +either) goes one step further for `setEntityMesh`/`setEntityMeshAsync` and +`setEntityAnimations`: instead of failing to resolve anything, each probes +for the format that needs disambiguating first, falling back to plain +`.untold`: + +```swift +// Resolves "model.untoldpack" if the source .blend had more than one +// independent model, otherwise "model.untold" — the caller doesn't need +// to know which one the export produced. +setEntityMeshAsync(entityId: entity, filename: "model") { success in ... } + +// Resolves "walk.untoldanim", or a plain "walk.untold" from before that +// extension existed. +setEntityAnimations(entityId: entity, filename: "walk", name: "Walk") +``` + +This is the recommended default for both calls — reach for an explicit +`withExtension` only when you need to force one specific file regardless of +what else exists at that name. If both candidates exist for a given base +name (normally impossible: the exporter keeps `.untold`/`.untoldpack` and +`.untoldanim`/`.untold` single-owner per name, removing the stale one on +re-export — see [Using the Untold Engine CLI](UsingUntoldEngineCLI.md)), the +first-priority format wins and a warning is logged. + For immediate loading, use: ```swift @@ -157,6 +182,13 @@ loadSceneAuthored(url: manifestURL) { success in Important behavior: +- `loadSceneAuthored(filename:withExtension:)` only ever resolves a single + `.untold` file — unlike `setEntityMeshAsync`/`setEntityAnimations`, it does + not probe for `.untoldpack`. A `.untoldpack` manifest has no scene-level + slot to begin with: a multi-model `.blend` scene's lights, cameras, and + color-grade LUT are dropped at export time (see [Using the Untold Engine + CLI](UsingUntoldEngineCLI.md#multi-model-blend-scenes--untoldpack)), so + there is nothing for this call to load for a pack regardless of extension. - Calling either overload clears any previously-loaded `.cube` grade first (`ColorGradeLUTParams.shared.clear()`), then re-populates it only if the asset/manifest actually has one staged. diff --git a/docs/API/UsingRenderingSystem.md b/docs/API/UsingRenderingSystem.md index 41b1b7a9a..316b95a0b 100644 --- a/docs/API/UsingRenderingSystem.md +++ b/docs/API/UsingRenderingSystem.md @@ -137,16 +137,23 @@ Use `.ssaoBlurred` in the debug view to inspect the final blurred occlusion text The engine can visualize individual G-Buffer layers and anti-aliasing internals in place of the final lit image: ```swift -setRendering(.debugView(.lit)) // Normal output (default) -setRendering(.debugView(.albedo)) // G-Buffer base color -setRendering(.debugView(.normal)) // G-Buffer surface normals -setRendering(.debugView(.position)) // G-Buffer world position -setRendering(.debugView(.depth)) // Linearized depth buffer (grayscale) -setRendering(.debugView(.ssaoBlurred)) // SSAO occlusion result -setRendering(.debugView(.fxaaEdgeDebug)) // FXAA luma-gradient edge map -setRendering(.debugView(.smaaEdges)) // SMAA edge detection output -setRendering(.debugView(.smaaBlend)) // SMAA blend-weight texture -setRendering(.debugView(.smaaDifference)) // Original vs. SMAA-resolved difference +setRendering(.debugView(.lit)) // Normal output (default) +setRendering(.debugView(.albedo)) // G-Buffer base color +setRendering(.debugView(.normal)) // G-Buffer surface normals +setRendering(.debugView(.position)) // G-Buffer world position +setRendering(.debugView(.depth)) // Linearized depth buffer (grayscale) +setRendering(.debugView(.roughness)) // G-Buffer roughness channel +setRendering(.debugView(.metallic)) // G-Buffer metallic channel +setRendering(.debugView(.ssaoBlurred)) // SSAO occlusion result +setRendering(.debugView(.occlusionDebug)) // Lit scene with green wireframe AABBs around HZB-occluded entities +setRendering(.debugView(.preTonemapHDRLuminance)) // Scene luminance before Look/ACES tonemapping +setRendering(.debugView(.postTonemapOutput)) // Post-tonemap output, routed explicitly for color-pipeline checks +setRendering(.debugView(.heightDebug)) // Raw height-map sample used by Parallax Occlusion Mapping +setRendering(.debugView(.pomOffsetDebug)) // Magnitude of the POM UV displacement, as a heatmap +setRendering(.debugView(.fxaaEdgeDebug)) // FXAA luma-gradient edge map +setRendering(.debugView(.smaaEdges)) // SMAA edge detection output +setRendering(.debugView(.smaaBlend)) // SMAA blend-weight texture +setRendering(.debugView(.smaaDifference)) // Original vs. SMAA-resolved difference ``` Restore normal rendering with `setRendering(.debugView(.lit))`. diff --git a/docs/API/UsingSpatialInput.md b/docs/API/UsingSpatialInput.md index 5dad7617c..dbe274c99 100644 --- a/docs/API/UsingSpatialInput.md +++ b/docs/API/UsingSpatialInput.md @@ -10,6 +10,56 @@ Spatial input in Untold Engine follows a simple pipeline: That separation keeps the system flexible: the OS-facing code stays in UntoldEngineXR, while gesture classification stays in the recognizer. +## XR Input Model + +### Selection rays follow interactions + +`XRSpatialInputState.rayOriginWorld` and `rayDirectionWorld` describe the selection ray supplied by a visionOS spatial event, in world coordinates. They are updated when the recognizer processes a primary interaction snapshot with a valid ray direction. This is an **event-driven selection ray**, not a continuously updated eye-gaze or head-forward ray. Looking around without interacting does not refresh these fields. + +An event can arrive without a selection ray, including on `.ended`. In that case, the recognizer keeps the previous ray. Frames without new snapshots also retain the ray, `currentPhase`, and `timestamp`. Before any valid ray arrives, both ray fields are zero; clearing XR input resets the state. A nonzero ray or a `.changed` phase alone therefore does not indicate new input in the current frame. `timestamp` records the processed primary snapshot's time, not necessarily the last valid ray's time. + +Use gesture signals to decide when to act: + +| Signal | How to use it | +|---|---| +| `spatialTapActive` | Handle a completed tap once. The recognizer sets it on `.ended` when the interaction did not become a drag, then clears it on the next input update. The ray may be retained from an earlier event in that interaction. | +| `spatialPinchActive` / `spatialDragActive` | Drive ongoing manipulation while the gesture is active. These signals do not guarantee a newly received selection ray each frame. | +| `spatialZoomActive` / `spatialRotateActive` and their deltas | Apply the current input update's two-hand gesture deltas; the recognizer clears these signals and deltas at the next update. | +| `.cancelled` | End manipulation without treating the interaction as a tap. The retained ray is not a new selection. | + +For example, raycast against a detected real surface when a tap completes: + +```swift +func handleInput() { + let state = getXRSpatialInputState() + guard state.spatialTapActive else { return } + + if let hit = pickRealSurfacePosition( + rayOrigin: state.rayOriginWorld, + rayDirection: state.rayDirectionWorld, + filter: .horizontalAny + ) { + Logger.log(message: "Tapped surface", vector: hit.worldPosition) + } +} +``` + +Do not restrict tap handling to `.began` or `.changed`: a completed tap is reported on `.ended`. For entity selection, use `pickedEntityId` and the picked position/normal fields, which preserve the selection captured at the start of a completed tap. For ongoing transforms, use the [manipulation lifecycle helpers](#quick-example) to handle begin, update, end, and cancellation. + +### Gaze fields do not supply live tracking + +`gazePosition` and `gazeDirection` are placeholders for future expansion. The XR runtime does not populate them with live eye-gaze or head-pose data; both default to zero. + +On visionOS, `InputSystem.shared.getGazeTarget(maxDistance:)` only calculates `gazePosition + normalize(gazeDirection) * maxDistance` from the stored fields. It does not query tracking or raycast the scene. It returns `nil` for the default zero direction, non-finite position/direction values, or a non-finite/non-positive distance. Supplying values yourself makes this a target-point calculation, not a live gaze accessor. + +The engine currently exposes no continuous gaze ray through these APIs. A head-forward direction would also be a different input from eye gaze. + +### The active camera entity is not the XR head pose + +`CameraSystem.shared.activeCamera` identifies the scene's camera entity. Reading that entity with `getPosition(entityId:)` returns its authored or application-updated position. The XR runtime does not synchronize its entity transform to the live ARKit device anchor, so this is not a head-position accessor. + +XR rendering instead derives per-eye view matrices from the ARKit device anchor and the compositor's eye transforms. That rendering pose is not published as a current head-pose accessor for game code. See the [XR rendering lifecycle](../Architecture/xrRenderingSystem.md#2e-device-anchor-acquisition) for how anchors are acquired and retained during tracking gaps. + ## What You Get in Game Code From XRSpatialInputState, you can read: @@ -416,41 +466,49 @@ Use this as the default scene-root helper when your app supports both panning an ## Combining Scene Drag, Rotate and Zoom -All three scene-level gestures can live in the same input loop — they gate on different input conditions so they don't conflict: +Scene drag/rotate and entity zoom can share an input loop, but choose explicit precedence: the unified lifecycle latches its drag/rotate mode, and adding a second hand does not automatically stop an already active drag. This example gives two-hand entity zoom priority and ends the scene session before scaling: ```swift func handleInput() { let state = getXRSpatialInputState() - // Single-hand pinch + drag → pan the scene - processAnchoredSceneDragLifecycle(from: state) - - // Two-hand pinch + twist → rotate the scene (yaw) - processAnchoredSceneRotateLifecycle(from: state) - - // Two-hand pinch + spread/pinch → zoom an entity - applyTwoHandZoomIfNeeded(from: state) + if state.currentPhase != .ended, state.currentPhase != .cancelled, + state.leftHandPinching, state.rightHandPinching, state.spatialZoomActive { + endAnchoredSceneManipulation() + applyTwoHandZoomIfNeeded(from: state) + } else { + processAnchoredSceneManipulationLifecycle(from: state) + } } ``` +`applyTwoHandZoomIfNeeded` changes a selected entity's **local scale** (its parent by default); it does not resize `SceneRootTransform`. For a tabletop model or an entire placed scene, use the [scene-root scaling recipe](#two-hand-scene-root-scaling) below instead. + For context-based entity vs. scene rotation — route two-hand twist to entity rotate when something is picked, and to scene rotate otherwise: ```swift func handleInput() { let state = getXRSpatialInputState() - // Scene-level drag (always active) - processAnchoredSceneDragLifecycle(from: state) + if state.currentPhase == .ended || state.currentPhase == .cancelled { + endAnchoredSceneManipulation() + return + } + + if state.leftHandPinching, state.rightHandPinching, state.spatialZoomActive { + endAnchoredSceneManipulation() + applyTwoHandZoomIfNeeded(from: state) + return + } if state.pickedEntityId != nil { + endAnchoredSceneManipulation() // Entity is picked → two-hand twist rotates the entity applyTwoHandRotateIfNeeded(from: state) } else { - // Nothing picked → two-hand twist rotates the scene - processAnchoredSceneRotateLifecycle(from: state) + // Nothing picked → drag or rotate the scene + processAnchoredSceneManipulationLifecycle(from: state) } - - applyTwoHandZoomIfNeeded(from: state) } ``` @@ -458,7 +516,7 @@ func handleInput() { ## Two-Hand Zoom -Apply the built-in zoom response: +Apply the built-in **entity** zoom response. This scales the target's local transform, even if you call that entity your "scene root"; it does not change the engine's `SceneRootTransform`. Its limits come from `setSpatialManipulation(.zoomScale(min:max:))`. ```swift let state = getXRSpatialInputState() @@ -484,6 +542,73 @@ if let picked = state.pickedEntityId { ------------------------------------------------------------------------ +## Two-Hand Scene-Root Scaling + +To resize an entire placed scene, use the public uniform `scaleSceneTo(_ scale: Float)` overload. It changes `SceneRootTransform` without rewriting entity transforms or rebuilding static batches. No picked entity is required. `getSpatialZoomDelta()` reports the frame's change in hand separation in meters (positive for spreading, negative for bringing hands together). The following app-defined response maps that delta through sensitivity to a multiplicative factor (`1 + delta * sensitivity`), once per frame, matching the entity helper's response: + +```swift +import UntoldEngine +import simd + +// Call scaleSceneTo(1.0) when initializing/resetting your placement. +// Configure these limits for your model's units and intended tabletop size. +func applySceneRootZoom( + from state: XRSpatialInputState, + sensitivity: Float = 1.0, + minScale: Float = 0.05, + maxScale: Float = 20.0 +) { + guard state.currentPhase != .ended, state.currentPhase != .cancelled, + state.leftHandPinching, state.rightHandPinching, state.spatialZoomActive, + sensitivity.isFinite, sensitivity > 0, + minScale.isFinite, maxScale.isFinite, + minScale > 0, maxScale >= minScale else { return } + + let delta = InputSystem.shared.getSpatialZoomDelta() * sensitivity + guard delta.isFinite, delta != 0 else { return } + + let factor: Float = 1 + delta + guard factor.isFinite, factor > 0 else { return } + + let current = SceneRootTransform.shared.scale + // This recipe expects an already uniform, positive scene scale. + guard current.x.isFinite, current.y.isFinite, current.z.isFinite, + current.x > 0, current.x == current.y, current.x == current.z else { return } + + let requested = current.x * factor + guard requested.isFinite else { return } + + let clamped = min(max(requested, minScale), maxScale) + scaleSceneTo(clamped) // Float overload writes the same value on all three axes. +} + +func handleInput() { + let state = getXRSpatialInputState() + + if state.currentPhase != .ended, state.currentPhase != .cancelled, + state.leftHandPinching, state.rightHandPinching, state.spatialZoomActive { + // Zoom owns this frame, even if the delta is rejected or at a limit. + endAnchoredSceneManipulation() + applySceneRootZoom(from: state, minScale: 0.05, maxScale: 20.0) + } else { + // Always forward end/cancel and release frames for lifecycle cleanup. + processAnchoredSceneManipulationLifecycle(from: state) + } +} +``` + +These are app-configured scene limits, independent of the entity helper's `.zoomScale` setting. Invalid limits, sensitivity, deltas, overflow, or a nonuniform/nonpositive current scale skip the write. Initialize the scene with a uniform scale inside your chosen range; after a valid update the scale stays within that range. Poll and apply the delta only once in each `handleInput()` frame; do not also call the entity zoom helper for the same scene-resize interaction. + +### Placement Pivot And Lifecycle Composition + +The scene-root matrix is `T * R * S`. Scaling keeps the **scene-local origin** fixed at `SceneRootTransform.shared.position` in visual world space; it changes neither position nor rotation. Author or arrange the model so its intended contact point (for example, the center of its base) is at scene-local `(0, 0, 0)`, then place that origin on the table using `translateSceneTo(position: hit.worldPosition)`. A contact point away from the origin will move when scaling: the pivot is not the picked entity, hand midpoint, or last surface hit. + +The input loop gives zoom priority over rotation if both signals are active. Ending the unified lifecycle clears its latched mode and cached drag position/rotation baseline. When zoom ends, drag/rotate begins a fresh session at the current placement, so a drag that started before zoom cannot restore an old placement. This intentionally sequences zoom and drag/rotate; it does not provide simultaneous three-way manipulation. Call `endAnchoredSceneManipulation()` on mode changes and before externally changing placement. If your app previously used the individual scene drag/rotate helpers, switch to this unified loop rather than continuing to call them alongside it. + +For a contact point away from the origin, keeping that point fixed would also require compensating the root translation: for scene-local pivot `p`, preserve `worldPivot = position + rotation.act(scale * p)` and set the new position to `worldPivot - rotation.act(newScale * p)`. Such translation must end/rebase any anchored drag session. It also changes the root origin used by scene yaw rotation, so the origin-based placement above is the recommended recipe when composing with the existing anchored rotation helper. + +------------------------------------------------------------------------ + ## Two-Hand Rotate Configure how the rotation axis is derived: @@ -567,6 +692,61 @@ To retrieve the exact world-space position where the user taps on a real-world s The `filter` parameter controls which planes are considered by **alignment** and, optionally, by **surface classification**. The function always returns the single closest hit that passes the filter. +### Hit fields and coordinate spaces + +`pickRealSurfacePosition` returns `RealSurfaceHit?`; it returns `nil` when no tracked plane qualifies. All input rays and returned positions use the session's **physical world coordinates**, not authored scene/entity coordinates. + +| Field | Meaning | +|---|---| +| `worldPosition: simd_float3` | Intersection point in physical world space, in meters. | +| `surfaceKind: RealSurfaceKind` | Detected plane classification: `.floor`, `.ceiling`, `.wall`, `.table`, `.seat`, `.door`, `.window`, or `.unknown`. | +| `distance: Float` | Distance from the input ray origin to `worldPosition`, in world-space meters. | +| `planeNormal: simd_float3` | Unit normal of the detected plane in world space. | +| `surfaceNormal: simd_float3` | Read-only alias of `planeNormal`. | + +Pass `XRSpatialInputState.rayOriginWorld` and `rayDirectionWorld` directly. The direction does not need to be normalized. `hitYRange` tests the intersection's physical world Y coordinate in meters. `maxDistance` limits the accepted hit distance in world-space meters and defaults to unlimited. Both `maxDistance` and the returned `distance` remain in physical meters when `SceneRootTransform` scales the authored scene; do not rescale the input ray or distance limit. + +### Where detected planes come from + +`TrackedPlane` is the core engine's platform-independent snapshot of a detected plane. It contains the anchor `id`, `originFromAnchorTransform` (anchor to physical world), `anchorFromExtentTransform` (extent to anchor), `extentWidth` and `extentHeight` in meters along the extent's local X and Z axes, `alignment`, and `classification`. Picking tests the finite extent rectangle, including its offset from the anchor origin. + +`RealSurfacePlaneStore.shared` is the thread-safe store of the latest `[TrackedPlane]`. The **`UntoldEngineXR` layer** populates it from ARKit's `PlaneDetectionProvider.anchorUpdates`, handling added, updated, and removed anchors. The core `UntoldEngine` picking function reads a `snapshot()` of that store; it does not start plane detection itself. See the [XR plane monitor](../Architecture/xrRenderingSystem.md#step-0-initialization) for the provider lifecycle. + +Normal XR application code consumes the store rather than populating it. `update(planes:)` replaces the entire set, `snapshot()` reads it, `clear()` empties it, and `logAllPlanes()` prints diagnostics. Without plane data, picking returns `nil`; check world-sensing authorization and allow time for scanning. + +### Tabletop placement with a scaled scene + +This example shrinks the authored scene to 1% of its size and places a top-level entity's origin on a detected table. Call the setup once, then call the placement function from your tap/update handler with an existing entity that has a transform: + +```swift +import simd +import UntoldEngine + +func configureTabletopScale() { + SceneRootTransform.shared.scale = simd_float3(repeating: 0.01) + SceneRootTransform.shared.updateIfNeeded() +} + +func placeOnTable(entityId: EntityID) { + let state = getXRSpatialInputState() + guard state.spatialTapActive, + let hit = pickRealSurfacePosition( + rayOrigin: state.rayOriginWorld, + rayDirection: state.rayDirectionWorld, + filter: .tableOnly, + maxDistance: 2.0 // Two physical meters, even at 1% scene scale. + ) + else { return } + + let scenePoint = SceneRootTransform.shared.visualWorldToSceneLocal(hit.worldPosition) + translateTo(entityId: entityId, position: scenePoint) +} +``` + +`worldPosition` stays on the physical table. Convert it with `visualWorldToSceneLocal(_:)` before using it as an authored scene position; the helper accounts for scene-root translation, rotation, and scale. With only a uniform `0.01` scale, a world point `(0, 0.75, 0)` becomes scene point `(0, 75, 0)`, while a hit one physical meter from the ray origin still reports `distance == 1`. Use `sceneLocalToVisualWorld(_:)` for the reverse conversion. + +`translateTo` sets an entity's local position. The example assumes a top-level entity; for a child, also convert the scene point into its parent's local space before passing it to `translateTo`. If the table remains `.unknown`, use `.horizontalAny` with a `hitYRange` chosen from observed world heights, as described below. + ### Alignment presets - `.horizontalAny` — horizontal planes only (floor, ceiling, table, seat). **Warning:** this includes tables and seats — use `.floorOnly` when you need the floor specifically. @@ -734,7 +914,7 @@ func applyWallAlignment(to modelRoot: EntityID) { } ``` -This sample assumes `modelRoot` is the top-level model entity you want to calibrate. If it has a parent transform, convert the target position and rotation into that parent space before calling `translateTo` or `rotateTo`. In a production calibration flow, keep the model's intended up axis stable when applying the rotation so wall alignment does not introduce unwanted roll. +This sample assumes `modelRoot` is the top-level model entity you want to calibrate and `SceneRootTransform` is identity. With a scene-root transform, convert the world points and normals into authored scene space before computing the alignment. If the entity has a parent transform, convert the target position and rotation into that parent space before calling `translateTo` or `rotateTo`. In a production calibration flow, keep the model's intended up axis stable when applying the rotation so wall alignment does not introduce unwanted roll. ### Choosing the right filter @@ -770,7 +950,7 @@ This reveals a common issue: **ARKit frequently classifies desks and tables as ` When ARKit does not classify a desk or table correctly, use the `hitYRange` parameter to restrict hits by the world-space Y coordinate of the intersection point. This is reliable regardless of classification. -Floor is always near Yā‰ˆ0. A standard desk or table is typically between 0.5m and 1.1m: +The floor's Y coordinate depends on the session's coordinate origin; it is not universally near Yā‰ˆ0. Choose height filters from observed plane positions (use `logAllPlanes()`), rather than assuming a fixed floor height. The following ranges assume a session where the observed floor is near Y=0 and the desk or table is between Y=0.5m and Y=1.1m. Adjust both ranges for your session; scene-root scaling does not change these physical world heights. ```swift let state = getXRSpatialInputState() diff --git a/docs/API/UsingTheExporter.md b/docs/API/UsingTheExporter.md index 3d087b3ab..d861008c4 100644 --- a/docs/API/UsingTheExporter.md +++ b/docs/API/UsingTheExporter.md @@ -73,7 +73,7 @@ untoldengine export \ Common options: - `--input `: required source `.usd`, `.usda`, `.usdc`, `.usdz`, or `.blend` -- `--output `: required destination `.untold` (or `.untoldanim` with `--animation`) +- `--output `: required destination `.untold` (or `.untoldanim` with `--animation`). A scene with several models is written as a `.untoldpack` of the same name; that name may be given as well - `--file-type `: optional, defaults to `tile` - `--mesh-name `: optional, export only one mesh from a multi-mesh asset - `--convert-orientation`: optional, convert the export into engine space @@ -144,11 +144,61 @@ Some material nodes are carried over instead of dropped: location, no rotation) is applied to the mesh's first UV map, so tiled textures keep their tiling. When textures use different Mapping nodes, the one most of them use is applied and the material fidelity report says so. -- An Invert node at full strength between an image texture and its socket (for - example a glossiness map feeding Roughness) is written into the staged texture, - saved as `_inverted.png`. +- Colour nodes between an image texture and its socket are written into the + staged texture, with the same math Cycles uses: Invert, Gamma, + Bright/Contrast, Hue/Saturation/Value, RGB Curves and ColorRamp (its Color + output). They are applied to linear values, so an sRGB texture is decoded and + encoded again. The texture is saved as `_inverted.png` for a lone Invert, + or as `_adj.png`. A node whose settings are themselves + linked to other nodes is still dropped, and the material fidelity report says + so. - A material whose surface is an Emission shader exports as an emissive material with a black base color. +- A Base Color, Roughness, Metallic or Emission input driven by node math with no + texture behind it (Mix, Math, RGB Curves, ColorRamp, node groups, ...) exports + the value the chain gives for a surface seen straight on. View-dependent nodes + such as Layer Weight and Fresnel take their straight-on value; the engine's own + Fresnel then brightens the edges. A chain with an image or procedural texture + in the way keeps the input's slider value, as before. +- Each mesh exports the material of the slot its faces use, which need not be the + first slot. +- EXR textures used by a material (a normal or metallic map, for example) are + converted to PNG. Values above 1 are clipped. + +Transparency becomes the engine's blended alpha mode: + +- A constant Alpha below 1 blends the material at that opacity. +- An Alpha fed by the base color image's own Alpha output uses that alpha. +- An Alpha fed by another texture, or through colour nodes, is written into the + alpha channel of the base color texture (a white one when the base color is a + constant), since the engine reads alpha from the base color texture. +- Glass is approximated, since the engine has no transmission: a Principled BSDF + with Transmission becomes a blended surface. Clear glass (a white base color) + keeps 10 % opacity at full transmission. The base color tints the light that + crosses glass, so tinted glass is more opaque by the light its color takes + (counted by its brightness), and black glass is exported opaque: the black + mirror it is in Blender. The metallic share of a surface lets no light + through, so a metal with Transmission left on is opaque as well. A rough + surface scatters what crosses it, so frosted glass is more opaque the rougher + it is. A base color, a metallic value or a roughness that comes from a texture + counts as clear, as no metal and as polished. Transparent BSDFs mixed in by a Mix Shader lower the opacity by their + share. A mix driven by Geometry > Backfacing takes its front-face side. The + material fidelity report lists these approximations. + +A height texture drives the engine's parallax occlusion mapping: + +- The image on a Displacement node's Height input exports as the height texture, + with the node's Scale as its depth, and failing that the image on the Height + input of a Bump node that feeds the Normal input, with its Distance. +- The engine's depth is a share of the texture's width, not a distance, so a + small Scale (0.02 to 0.1) carries over well and may need tuning after import. +- A Scale or Distance above 0.2 is not a depth parallax can show. Blender leaves + both at 1, a metre, and with its default "Bump Only" displacement draws the + shading of a bump from them. Such a height is left out of the export, and the + material fidelity report says so; the surface keeps its normal map. + +Lights and cameras follow the same rules as objects: never from collections +excluded from the view layer, and hidden ones only with `--include-hidden`. ## Bake Textures To `.utex` @@ -396,6 +446,11 @@ At runtime, `NM_` objects default to `.selectableGeometry` and `.preserveIdentit After exporting assets, use [Optimizations](Optimizations.md) for optional workflows such as ASTC texture compression and LZ4 geometry compression. +The export scripts write a `.untoldpack` without LOD chains. `untoldengine +export` adds them as its last step; for a pack written by `scripts/export-untold` +or by the Blender add-on, run `untoldengine bake-lods --input .untoldpack` +afterwards (see [LOD chains for packs](UsingUntoldEngineCLI.md#lod-chains-for-packs)). + ## Loading The Result In The Engine Single asset: diff --git a/docs/API/UsingUntoldEngineCLI.md b/docs/API/UsingUntoldEngineCLI.md index 74a1c2665..494a560c1 100644 --- a/docs/API/UsingUntoldEngineCLI.md +++ b/docs/API/UsingUntoldEngineCLI.md @@ -153,21 +153,32 @@ macOS location and is not available on `PATH`. If the source `.blend` scene contains more than one independent model (more than one object with no parent among the exported objects), the exporter writes a `.untoldpack` manifest next to `--output` instead of a single -`.untold` file, plus one self-contained `.untold` per model under its own -subfolder: +`.untold` file, plus one `.untold` per model under its own subfolder and one +`Textures/` folder the models share: ```bash untoldengine export \ --input warehouse.blend \ --output warehouse.untold \ --convert-orientation --optimize -# → warehouse.untoldpack, Shelf/Shelf.untold, Forklift/Forklift.untold, ... +# → warehouse.untoldpack, Shelf/Shelf.untold, Forklift/Forklift.untold, ..., Textures/ ``` -`--optimize` bakes textures for every model in the pack. Load the result with -`setEntityMeshAsync(entityId:filename:withExtension:)` using `"untoldpack"` — -the engine loads a pack the same way it loads a single `.untold`, placing -each model as a child entity. See [Using the Registration +A model placed more than once (a shelf copied fifty times, a tree planted all +over a site) is written once: every placement in the manifest points at the +same `.untold`, with its own transform. Copies are found by content: the same +geometry, materials and hierarchy, wherever they stand and whatever their +names; meshes with no material all get the same default one, so their copies +count as well. Skinned models and models with morph targets are always written +on their own. Objects that share one mesh datablock in Blender (linked +duplicates) and have no modifiers are also split and extracted once, which is +most of the export time on scenes full of copies. + +`--optimize` bakes the shared textures once for the whole pack. Load the result with +`setEntityMeshAsync(entityId:filename:)`, leaving `withExtension` out — +the engine probes for `.untoldpack` then `.untold` automatically, so the same +call works whether the scene exported as one file or a pack, placing each +pack model as a child entity. See [Using the Registration System](UsingRegistrationSystem.md). Re-exporting the same `--output` path after the scene's model count changes @@ -176,6 +187,59 @@ Re-exporting the same `--output` path after the scene's model count changes longer references, so a caller never picks up a leftover file from an older export by accident. +**A pack carries no scene-level data.** Scene-authored lights, cameras, and +a `--color-grade-lut` are extracted from the `.blend` scene but have nowhere +to go in a `.untoldpack` — there's no scene-wide slot in the manifest, only +one self-contained `.untold` per model. The exporter prints a console note +when this happens (and the Blender add-on's status bar shows the same +warning). `loadSceneAuthored` (see [Using the Registration +System](UsingRegistrationSystem.md)) only ever resolves a single `.untold` +file, so it has nothing to do for a pack — recreate those lights/cameras by +hand in the scene you build from the pack's models, or keep the source +`.blend` to a single root model if you need them carried automatically. + +The export also builds a LOD chain for every model of the pack that is detailed +enough to need one, so a large scene stays drawable from a distance without +hand-made LODs. See [LOD chains for packs](#lod-chains-for-packs); pass +`--no-lods` to leave them out. + +### LOD chains for packs + +`untoldengine bake-lods` writes simplified copies of a model next to it +(`_LOD1.untold`, `_LOD2.untold`, ...) and, for a pack, records them +in the manifest. The engine loads the levels with the pack and switches each +placement between them by its size on screen (see [Automatic LOD chains for +packs](UsingLODSystem.md#automatic-lod-chains-for-packs)). + +`untoldengine export` runs this step for the packs it writes. Use the command +on its own for a pack cooked earlier, or to cook with other settings; it works +from the cooked files, so neither the source asset nor Blender is needed, and +running it again replaces the levels of the run before: + +```bash +untoldengine bake-lods --input GameData/Models/site.untoldpack + +# Four levels instead of three, and chains for smaller models too +untoldengine bake-lods --input site.untoldpack --ratios 0.5,0.25,0.1,0.02 --min-triangles 500 + +# The levels of one model, without a pack +untoldengine bake-lods --input Models/tree/tree.untold +``` + +| Flag | Description | +|---|---| +| `--input ` | The `.untoldpack` manifest, or one `.untold` model | +| `--ratios ` | Share of the triangles each level aims for, finest first (default `0.5,0.15,0.03`) | +| `--min-triangles ` | Models with fewer triangles get no chain (default `2000`) | +| `--pixels-per-triangle ` | Screen pixels a triangle of a level covers when the level takes over, on a viewport 1080 pixels high (default `4`); higher values switch to the simpler levels sooner | + +A model placed many times is simplified once. A level the simplifier cannot +reduce by a worthwhile amount is left out, and skinned, morphing and +animation-only models keep their full meshes. Vegetation and similar models +made of thousands of small separate pieces are thinned evenly, with the +remaining pieces enlarged so that a canopy keeps its density. The simplifier is +[meshoptimizer](https://github.com/zeux/meshoptimizer). + ### Animation-only exports → `.untoldanim` `--animation` exports clip data only (no mesh geometry) and requires a @@ -190,7 +254,9 @@ untoldengine export \ `.untoldanim` is a plain `.untold` container under the hood, named distinctly so it's never mistaken for a mesh — `setEntityMeshAsync` rejects it; load it -with `setEntityAnimations(entityId:filename:withExtension:name:)` instead. +with `setEntityAnimations(entityId:filename:name:)` instead, again leaving +`withExtension` out — it probes `.untoldanim` then `.untold`, covering assets +exported before `.untoldanim` existed. ### Gaussian splats → `.untoldgs` @@ -220,6 +286,7 @@ re-export from the source tool as v2/v3, or convert through `.ply` instead. | `--compress-geometry` | LZ4-compress vertex/index chunks | | `--assets-dir ` | Folder for the textures and per-model folders the result references (default: the `--output` folder) | | `--include-hidden` | Also export objects hidden in the viewport or disabled in renders | +| `--no-lods` | Do not build the [LOD chains](#lod-chains-for-packs) of a pack's models | | `--validate` | Write a companion validation JSON file | | `--color-grade-lut ` | Stage an externally-authored `.cube` 3D LUT and apply it as a post-tonemap creative grade (no Blender render, no conversion) — see [Using Color Management](UsingColorManagement.md) | @@ -334,6 +401,43 @@ scale s`). --- +## Baking an ML Deformer Training Set + +`untoldengine bake-mldeformer` plays animation clips (plus smooth random +augmentations) through the volumetric XPBD muscle simulation of a rigged +`.untold` asset and records pose features and the skin deltas the muscles +produce, every few frames, into a dataset you train a model on: + +```bash +untoldengine bake-mldeformer --untold Models/hero/hero.untold \ + --clip Animations/hero_flex/hero_flex.untoldanim \ + --muscles hero.muscles.json --output bake/hero --passes 8 +``` + +This writes `bake/hero.json`, `bake/hero.features.f32`, and +`bake/hero.deltas.f16`. Train the network with `scripts/train_mldeformer.py`, +which writes the `.untoldml` sidecar the runtime loads from next to the +asset — see [`muscleDeformation.md`](../Architecture/muscleDeformation.md). + +| Flag | Description | Default | +|---|---|---| +| `--untold ` | The rigged `.untold` asset | — | +| `--clip ` | Animation clip (`.untoldanim`); repeatable | — | +| `--muscles ` | Muscle rig JSON (same format `untoldexplorer.py --muscles` consumes) | asset's muscle table | +| `--output ` | Output base path, without extension | — | +| `--passes ` | Augmentation passes per clip | `6` | +| `--every ` | Record a sample every N simulated frames | `4` | +| `--settle ` | Settle frames before the first sample of a play | `24` | +| `--max-angle-degrees ` | Largest augmentation rotation per joint | `25` | +| `--frame-rate ` | Simulation frame rate | `90` | +| `--seed ` | Random seed | `1` | + +If `--muscles` is omitted, the asset's own muscle table is used; the command +fails with a clear error if neither is present. At least one `--clip` is +required. + +--- + ## Partitioning Scenes into Streaming Tiles For large outdoor scenes, `export-tiles` partitions a USD/USDZ/`.blend` scene into diff --git a/docs/Architecture/animationPoseLayer.md b/docs/Architecture/animationPoseLayer.md index a19a699e2..8fbf3b853 100644 --- a/docs/Architecture/animationPoseLayer.md +++ b/docs/Architecture/animationPoseLayer.md @@ -1,190 +1,105 @@ -# Animation Pose Layer +# Animation Pose Pipeline -Status: **design — pending review** -Branch: `feature/animation_pose_layer` +Status: **shipped** — covers the full animation foundation and character +deformation stack: pose layers, physics pose, compute skinning, DQS/DDM, morph +targets, pose drivers, XPBD muscles, ML deformer, and motion-capture seams. ## Purpose -This document specifies the first milestone (M1) of the data-driven character -animation effort: a runtime **pose layer** that upgrades the engine's skeletal -animation from "one clip, hard cuts, no locomotion" to a foundation that -supports motion matching and, later, learned controllers. - -The overall roadmap (see *Context* below) is: - -| Milestone | Deliverable | -|---|---| -| **M1 (this doc)** | Indexed pose representation, allocation-free clip sampling, inertialized transitions, root motion | -| M2 | Two-bone foot IK using the existing ray-picking systems | -| M3 | Motion matching: feature database, nearest-neighbour search, trajectory queries from `SteeringSystem` | -| M4 | Demo: AI character locomoting over real terrain on visionOS with no animation state machine | -| Later | Learned Motion Matching (network compression of the database); physics-based controllers | - -M1 is valuable on its own even if nothing after it ships: every consumer of -`changeAnimation` gets smooth transitions, and locomotion clips stop sliding. - -## Context: what exists today - -- `Skeleton` (`Sources/UntoldEngine/Mesh/Skeleton.swift`) stores parallel - arrays of `simd_float4x4` keyed by joint **string paths**. -- `AnimationClip.jointAnimation` is a `[String: Animation]` dictionary; every - joint lookup, every frame, hashes a string path. -- `Animation.interpolateKeyframes` builds a `(previous, next)` tuple **array - per joint per channel per frame** and linear-scans it — an allocation and an - O(keys) walk in the hottest loop of the system. -- `changeAnimation` swaps `currentAnimation` with an instant pop; `currentTime` - is not even reset, so the new clip starts at an arbitrary phase. -- Root joint translation is baked into the pose: a walk clip drags the mesh - away from the entity transform, then snaps back on loop - (`updateWorldPose` wraps with `fmod`). -- The per-skin joint `MTLBuffer` is single-buffered and CPU-written while - earlier frames may still read it (contrast `TripleBuffer.swift` used for - per-mesh uniforms). - -None of this matters at "one idle clip per entity". All of it matters when a -controller samples multiple clips per frame and transitions constantly. - -## Design - -Four pieces, deliberately layered so each lands as its own PR with tests. - -### 1. Indexed pose representation + compiled clips - -New directory `Sources/UntoldEngine/Animation/`. - -**Pose buffer** — structure-of-arrays, local space, indexed by skeleton joint -index (same order as `Skeleton.jointPaths`): - -```swift -struct PoseBuffer { - var translations: [simd_float3] // local, per joint - var rotations: [simd_quatf] // local, per joint - // scale is not animated by the runtime format; the rest-pose local - // scale is folded in when matrices are built (matches current - // AnimationClip.getPose behavior). -} -``` - -Local quaternions + translations are the canonical runtime representation. -Matrices are derived at the end of the frame; nothing blends matrices. (The 6D -rotation representation from the research plan is a *network I/O* format for -M3+/ML; it does not appear in the runtime.) - -**Compiled clip** — built once when a clip is bound to a skeleton: - -```swift -final class CompiledAnimationClip { - let name: String - let duration: Float - // Per skeleton-joint channel, index-aligned with Skeleton.jointPaths. - // Joints the clip does not animate fall back to the rest pose at - // compile time — no per-frame dictionary miss handling. - let rotationTimes: [[Float]] // per joint, sorted - let rotationValues: [[simd_quatf]] - let translationTimes: [[Float]] - let translationValues: [[simd_float3]] - // Root motion metadata (section 3) - let rootDisplacementPerLoop: simd_float3 - let rootYawPerLoop: Float -} -``` - -The string-path → index resolution happens exactly once, at compile time, -using the existing `Skeleton.mapJoints`. The existing `AnimationClip` remains -the loader-facing type; `AnimationComponent` gains a lazily-built -`compiledClips: [String: CompiledAnimationClip]` cache keyed by clip name. - -**Sampler** — allocation-free, binary search with a per-player cursor hint -(consecutive frames advance monotonically, so the hint hits almost always): - -```swift -struct ClipSampler { - var cursor: [Int] // per-joint last keyframe index hint - mutating func sample(_ clip: CompiledAnimationClip, at time: Float, - into pose: inout PoseBuffer) -} -``` - -`Skeleton` gains one method to close the loop with the existing renderer: - -```swift -func updateWorldPose(from localPose: PoseBuffer) -``` - -which composes the hierarchy exactly like the current `computeWorldPose` -(including the inverse-bind multiply) and writes `currentPose`. GPU skinning, -shaders, and `Skin.updateJointMatrices` are untouched. - -**Compatibility gate:** unit tests sample the same clip through the old path -(`AnimationClip.getPose`) and the new path and assert per-joint equality -within epsilon. The old sampling code is removed only after that test is -green; `AnimationSystem.update` then routes through the compiled path -unconditionally. - -### 2. Inertialized transitions - -Replaces nothing (there is no blending today) and does not introduce -crossfades. On `changeAnimation`, instead of popping: - -1. Sample the **outgoing** state one last time (pose + per-joint velocity, - estimated by finite difference over the previous frame). -2. Compute the per-joint **offset** from the incoming clip's pose at its - start time: translation offset as a vector, rotation offset as a rotation - vector (quaternion log), plus offset velocities. -3. Each frame, decay the offset toward zero with a critically damped - spring (Daniel Holden's inertialization formulation, halflife-parameterized) - and add it on top of the incoming clip's sampled pose. - -State lives in a `PoseTransition` struct on `AnimationComponent` (offset -buffers + halflife + remaining flag). Cost: two `PoseBuffer`s per entity and -a few fused multiply-adds per joint — no second clip is sampled after the -transition frame, which is the point of inertialization vs. crossfade. - -Public API — extend, don't break: - -```swift -public func changeAnimation(entityId: EntityID, name: String, - transitionHalflife: Float = 0.1, - withPause: Bool = false) -``` - -`transitionHalflife: 0` reproduces today's hard cut. `currentTime` resets to -0 on change (bug fix; noted in the PR). - -### 3. Root motion - -Opt-in per entity (default off — existing content behaves exactly as today): - -```swift -public func setRootMotionEnabled(entityId: EntityID, enabled: Bool, rootJointPath: String? = nil) -``` - -When enabled, for the designated root joint (the first joint with a `nil` -parent, overridable by joint path): - -- The sampler extracts the root's **horizontal translation delta and yaw - delta** per frame in character space. Loop wrap is handled with the - precomputed `rootDisplacementPerLoop` / `rootYawPerLoop`: when the clip - time wraps, the delta is `(end→loopEnd) + (loopStart→newTime)` rather than - the raw negative jump. -- Those deltas are applied to the entity via the existing - `translateBy` / `rotateBy` (`TransformSystem`), rotated into world space by - the entity's current orientation. -- The root joint's horizontal translation and yaw are **removed from the - pose** (vertical motion, pitch and roll stay — a stumbling zombie leans). - -This runs inside `AnimationSystem.update`, which executes before -`PhysicsSystem` in the frame (`UntoldEngine.swift` update order), so physics -and steering see the post-root-motion transform in the same frame. - -### 4. Joint buffer frames-in-flight fix - -Independent `[Patch]` PR: `Skin.jointTransformsBuffer` becomes a ring of -`totalPerMeshUniformBuffers()` buffers indexed by the frame-in-flight counter, -mirroring the existing per-mesh uniform pattern, with the bind site in -`RenderPasses.swift` selecting the current slot. Today's single buffer is a -latent CPU-write/GPU-read race that becomes visible tearing the moment poses -change every frame on multiple entities. +`Sources/UntoldEngine/Animation/` is a data-driven character animation +pipeline that replaced the engine's original "one clip, hard cuts, no +locomotion" skeletal animation with indexed, allocation-free clip sampling, +inertialized transitions, root motion, pose layering, two kinds of IK, motion +matching, and a physics-pose seam. It is built **in-engine**, not as a +pluggable external system — see *Extensibility* below for why. + +This document describes the pose-production pipeline: turning clips (and, +optionally, motion matching or a physics plugin) into the per-frame joint pose +that gets composed into world space and uploaded to the skin. It does not +cover the post-skinning deformation stack (compute skinning variants, DQS/DDM, +morph targets, XPBD muscles, the ML deformer) — that lives downstream of this +pipeline's output and is documented separately in +[`muscleDeformation.md`](muscleDeformation.md). + +## Runtime representation + +**Pose buffer** (`PoseBuffer.swift`) — structure-of-arrays, local space, +indexed by skeleton joint index (same order as `Skeleton.jointPaths`): +translations (`[simd_float3]`) and rotations (`[simd_quatf]`). Scale is not +animated by the runtime format; rest-pose local scale is folded in when +matrices are built. + +**Compiled clip** (`CompiledAnimationClip.swift`) — built once when a clip is +bound to a skeleton. The loader-facing `AnimationClip`'s string-path joint +lookups are resolved to skeleton-joint-index-aligned arrays exactly once, at +compile time (via `Skeleton.mapJoints`); joints the clip doesn't animate fall +back to the rest pose at compile time, so there's no per-frame dictionary-miss +handling. `AnimationComponent` lazily builds and caches `compiledClip(for: +skeleton:)` per clip name. + +**Sampler** (`ClipSampler.swift`) — allocation-free, binary search with a +per-player cursor hint; consecutive frames advance monotonically so the hint +hits almost always. + +## Per-frame pipeline + +`updateAnimationSystem` (`Sources/UntoldEngine/Systems/AnimationSystem.swift`) +runs this sequence per entity, every frame, in this order: + +1. **Motion matching** (if `animationComponent.motionMatching.isEnabled`) may + switch the current clip/time before anything else samples it — it reads + the goal trajectory and feature database and picks the best-matching + clip/time for this frame. See `MotionMatching.swift`/`MotionDatabase.swift` + and [`UsingMotionMatching.md`](../API/UsingMotionMatching.md). +2. **Sample** the compiled clip into `localPose` (raw, no transitions applied + yet). +3. **Root motion** (`RootMotion.swift`) runs on the *raw* sampled pose, before + transition offsets — deltas come straight from the clip; extracts the root + joint's horizontal translation/yaw delta per frame, applies it to the + entity transform, and removes it from the pose (vertical motion, pitch, + and roll stay in the pose — a stumbling zombie still leans). Opt-in per + entity via `setRootMotionEnabled`. See + [`UsingRootMotion.md`](../API/UsingRootMotion.md). +4. **Inertialized transition** (`Inertialization.swift`) decays in real time + (independent of playback speed) toward zero offset, blending the outgoing + pose's offset on top of the incoming clip — no second clip is sampled + after the transition frame, which is the point of inertialization over + crossfade. Triggered by `changeAnimation(transitionHalflife:)`. See + [`UsingAnimationTransitions.md`](../API/UsingAnimationTransitions.md). +5. **External pose** (`ExternalPose.swift`) overrides the animated rotations + of whichever joints a motion-capture (or other external) source is + driving, on top of the transitioned pose. +6. **Pose layer** (`PoseLayer.swift`) — one override clip, sampled on its own + clock, whose local rotations replace those of a joint subset (given as + subtree roots, e.g. both clavicles → the whole upper body). Layer clip + switches crossfade over a halflife, and influence eases toward a target + weight, so a posture change reads as a movement rather than a cut. Runs + after the base clip's transition but before IK, so motion matching (which + reads feet/hips) never sees it, and reach IK bends the layered arms. See + [`UsingPoseLayers.md`](../API/UsingPoseLayers.md). +7. **Reach IK** (`ReachIK.swift`) — multi-chain IK (e.g. hand-to-target) + applied after the pose layer. +8. **Foot IK** (`FootIK.swift`) — corrects the final pose by planting feet on + real geometry (via a ground query), after root motion and transitions have + settled the pose. Includes optional stance locking + (`FootIKStanceLockSource`). See [`UsingFootIK.md`](../API/UsingFootIK.md). +9. **Physics pose** (`PhysicsPose.swift`) lands last, on the fully animated + pose: for the joints it weights, a physics plugin's bodies win over every + stage above. The animation's own local pose is captured aside first (via + `Skeleton.captureAnimatedPose`) so a plugin driving its bodies toward the + animation never chases its own already-blended result; it's restored after + skinning (`restoreAnimatedLocalPose`) so pose history for the *next* + frame's transitions/inertialization stays the animation's own, not the + physics-blended one. See [`UsingPhysicsPose.md`](../API/UsingPhysicsPose.md). +10. **Compose** — `Skeleton.updateWorldPose(from:localScales:)` turns the + final local pose into world-space joint matrices (hierarchy compose, + including the inverse-bind multiply), matching the pre-pipeline + `computeWorldPose` behavior exactly. +11. **Skin** — `Skin.updateJointMatrices(skeleton:)` per mesh in the render + component. GPU skinning, shaders, and the skin buffer itself are + unaffected by anything above; downstream deformation (compute skinning + variants, DQS/DDM, morph targets, XPBD muscles, ML deformer) consumes this + output — see [`muscleDeformation.md`](muscleDeformation.md). ## Extensibility: where the plugin seam is @@ -192,85 +107,64 @@ A recurring question: should this live outside the engine as a plugin, with the engine exposing only a minimal API — so a game could swap in a different animation system later? -The realistic future is not "one game uses a different animation system"; it -is "one *scene* uses several at once": crowd characters on motion matching, -props on plain clip playback, a hero character on a learned controller. That +The realistic case is not "one game uses a different animation system"; it is +"one *scene* uses several at once": crowd characters on motion matching, props +on plain clip playback, a hero character with physics-driven joints. That argues for a seam **per entity**, not a globally replaceable system. The layering is: ``` ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ Controllers (pluggable, per entity) │ -│ built-in clip player Ā· motion matching (M3) Ā· ML │ +│ built-in clip player Ā· motion matching Ā· │ +│ physics pose (external bodies) Ā· motion capture │ ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ │ Pose machinery (engine-owned, this doc) │ │ PoseBuffer Ā· compiled clips Ā· sampler Ā· │ │ inertialization Ā· root motion application Ā· │ -│ hierarchy compose Ā· skinning upload │ +│ pose layer Ā· reach/foot IK Ā· hierarchy compose Ā· │ +│ skinning upload │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ``` -The contract between the layers is small: *given an entity and a delta time, -fill a `PoseBuffer` (local space) and optionally report a root-motion delta*. -Everything below that line is machinery every controller needs and should not -be reimplemented per plugin; everything above it is strategy. - -Consequences for M1: - -- The machinery is built **in-engine** (as decided), but `AnimationSystem`'s - update is structured as *evaluate controller → inertialize → apply root - motion → compose → skin* from the start, with the existing clip player as - the built-in controller. No public protocol yet. (Since M1 the layer, reach - IK and foot IK stages sit between root motion and compose, and a - **physics pose** — a model-space pose a physics plugin hands in through - `setPhysicsPose`, blended per joint — is the last stage before compose; - see `docs/API/UsingPhysicsPose.md`.) -- The controller interface (`PoseBuffer` + an `AnimationPoseController` - protocol + registration) is **published only when M3 exists** — motion - matching is the first real external consumer, and freezing a public API - before a second implementation has exercised it reliably produces the - wrong API. Publishing it is a small `[Feature]` PR at that point; under - the repo's auto-semver it must be additive. -- Until then nothing new is `public`; the engine's API surface (and semver) - is untouched by PRs ①–⑔. - -## What M1 does not do - -- No motion database, feature vectors, or nearest-neighbour search (M3). -- No IK (M2). -- No layered/partial-body blending, no state machines — the target - architecture (motion matching) does not need them. -- No new shaders or metallib changes; skinning stays as-is. -- No changes to the `.untold` format or the Blender exporter. (M3 will need - an offline motion-database builder that consumes `.untold` clips; format - additions are deferred until then.) - -## PR breakdown - -Per the contribution guidelines (one feature per PR, tests required, -how-to guide for new systems): - -1. `[Feature]` Compiled clips + allocation-free pose sampling — internal, - behavior-identical, gated by the old-vs-new equality test. -2. `[Patch]` Joint transform buffer frames-in-flight ring. -3. `[Feature]` Inertialized animation transitions — public API change, - how-to guide (`docs/API/UsingAnimationTransitions.md`), demo snippet. -4. `[Feature]` Root motion — public API, how-to guide, test with a synthetic - clip whose root displacement is known analytically. - -## Testing - -- **Equality gate:** old sampler vs. compiled sampler, all clips in the test - assets, epsilon 1e-5. -- **Sampler correctness:** synthetic clips with hand-computed keyframe values - (mid-key interpolation, exact-key hits, wrap, single-key channels, unanimated - joints preserving rest translation/scale). -- **Inertialization:** offset decays monotonically to zero within ~4Ɨ - halflife; zero halflife reproduces a hard cut; pose is continuous (C0) at - the transition frame by construction. -- **Root motion:** synthetic 4-key straight-walk clip — accumulated entity - displacement over exactly N loops equals N Ɨ known displacement; no snap at - the wrap frame. -- **Performance guard:** the sampler's buffers must not grow after warm-up - (steady-state sampling reuses `PoseBuffer` and scratch storage), plus a - `measure`-based regression test over many sequential frames. +The contract between the layers stays small: *given an entity and a delta +time, fill (or override part of) a `PoseBuffer` (local space), optionally +reporting a root-motion delta or taking over weighted joints*. Everything +below that line is machinery every controller needs and should not be +reimplemented per plugin; everything above it is strategy. + +There is still no general-purpose public `AnimationPoseController` protocol — +each controller (motion matching, physics pose, external/motion-capture pose, +pose layer) is a dedicated built-in stage with its own public setup API +(`setMotionMatching`, `setPhysicsPose`, `setPoseLayerMask/Clip/Weight`, etc.) +rather than a registrable plugin type. Publishing a generic controller +protocol remains deferred until a concrete third-party use case needs it; the +cost of freezing the wrong API before a second independent implementation has +exercised it reliably still outweighs the convenience. + +## What this pipeline does not do + +- No layered/partial-body **state machines** — pose layer crossfades and + inertialized transitions cover the cases this engine's consumers need + without one. +- No new shaders or metallib changes from the pose pipeline itself; skinning + consumes its output unchanged. (The separate deformation stack in + `muscleDeformation.md` does add shaders/kernels, downstream of this.) +- No changes to the `.untold` format from the pose pipeline itself (motion + matching's offline database builder and the muscle/ML-deformer work added + their own format additions, documented in their own docs). + +## See Also + +- [`UsingAnimationSystem.md`](../API/UsingAnimationSystem.md) — basic clip + playback and policy API +- [`UsingAnimationTransitions.md`](../API/UsingAnimationTransitions.md) — + inertialized transitions +- [`UsingPoseLayers.md`](../API/UsingPoseLayers.md) — pose layer + reach IK API +- [`UsingFootIK.md`](../API/UsingFootIK.md) — foot IK API +- [`UsingRootMotion.md`](../API/UsingRootMotion.md) — root motion API +- [`UsingPhysicsPose.md`](../API/UsingPhysicsPose.md) — physics pose seam +- [`UsingMotionMatching.md`](../API/UsingMotionMatching.md) — motion matching + API +- [`muscleDeformation.md`](muscleDeformation.md) — post-skinning deformation + stack (compute skinning, DQS/DDM, morph targets, XPBD muscles, ML deformer) diff --git a/docs/Architecture/lodSystem.md b/docs/Architecture/lodSystem.md index d213ff2f7..23c78e367 100644 --- a/docs/Architecture/lodSystem.md +++ b/docs/Architecture/lodSystem.md @@ -7,7 +7,7 @@ UntoldEngine has two separate LOD mechanisms that operate at different granulari | Unit | Individual mesh entity (`LODComponent`) | Whole tile `.untold` file (`TileLODLevel`) | | Control | `LODSystem` — called every frame, but the full entity pass is throttled to every `lodUpdateFrameInterval` frames (default 4) unless the camera moved past `minimumCameraDisplacementForLODUpdate` (default 0.5 units) | `GeometryStreamingSystem.update()` — runs per tick | | Switch trigger | Camera distance vs `LODLevel.maxDistance` | Camera distance vs `TileLODLevel.switchDistance` (with hysteresis) | -| Hysteresis | 5-unit inner band on finer-LOD transitions only | `lodHysteresisFactor` (default 0.90 = 10% band) on active level | +| Hysteresis | 5-unit inner band on finer-LOD transitions only, at most a tenth of the level's switch distance | `lodHysteresisFactor` (default 0.90 = 10% band) on active level | | Meshes in memory | All LOD levels GPU-resident simultaneously | Only the active LOD level is loaded | | Use case | Individual detailed objects (buildings, props) | Tile-granularity intermediate representations for large scenes | | Content pipeline | Separate `.untold` files per LOD level, wired via `LODComponent` | Separate `.untold` files per tile LOD, listed in manifest `lod_levels` array | @@ -80,7 +80,7 @@ If adjustedDistance ≤ 200 → desiredLOD = 2 (low) Beyond all thresholds → desiredLOD = 2 (lowest available) ``` -**Hysteresis:** When switching to a *finer* LOD (e.g. camera approaching, LOD2→LOD1), the threshold is tightened by `5.0` units. This prevents flickering when the camera hovers right at a boundary. Switching to *coarser* LODs has no penalty — it happens immediately. +**Hysteresis:** When switching to a *finer* LOD (e.g. camera approaching, LOD2→LOD1), the threshold is tightened by `5.0` units, and never by more than a tenth of the threshold itself (`lodHysteresisDistanceShare`): a level that switches a few units from the camera would otherwise never be returned to. This prevents flickering when the camera hovers right at a boundary. Switching to *coarser* LODs has no penalty — it happens immediately. --- diff --git a/docs/Architecture/renderingSystem.md b/docs/Architecture/renderingSystem.md index 2ce9206bf..8fdf7f2c9 100644 --- a/docs/Architecture/renderingSystem.md +++ b/docs/Architecture/renderingSystem.md @@ -59,6 +59,8 @@ In addition to writing the GPU visibility result, `executeFrustumCulling` stores For XR, a reduce-scan variant runs the test against both eyes simultaneously. +**Small-object culling:** before the GPU test, the CPU loop that gathers the AABBs leaves out every entity whose bounding sphere is under `SmallObjectCulling.minimumPixels` tall on screen (1 pixel by default, set with `setRendering(.smallObjectCulling(pixels:))`, 0 to turn it off). The size comes from the sphere's radius, its distance to the camera, the projection's vertical scale and the viewport height, so it follows the field of view and the resolution; it is skipped under an orthographic projection, and an entity whose bounds have no size is never culled by it. An entity left out is not in the visible set, so no pass draws it. When nothing is left to test, an empty visible set is published instead of keeping the previous one. + ### 3b. Gaussian Frustum Culling → `executeGaussianFrustumCulling(commandBuffer)` For entities carrying a `GaussianComponent` (3D Gaussian splat data), a compute pass culls splats against the camera frustum (and the previous frame's HZB) before the more expensive depth and sort passes run on them. Surviving splat indices are appended to a per-frame list with an atomic counter. @@ -173,7 +175,7 @@ The cascade count is 2 by default. Raise to 3 in `Globals.swift` for outdoor sce **Cascade transitions:** The final 10% of each non-final cascade overlaps the next cascade. `ShadowSystem.cascadeBlendStart` computes this boundary once per frame on the CPU and is the single source of truth for it: the same value both widens the next cascade's frustum-fitting near plane and is uploaded to the shader (`cascadeBlendStarts`) for the cross-fade, so the two are never independently re-derived and cannot drift out of sync. The lighting shader samples both slices in the overlap and blends smoothly between them, avoiding a hard visibility change when camera motion moves stationary geometry across a split. -**Directional caster culling:** Each cascade culls shadow casters in two stages. First, a direction-agnostic world-space distance reject: a caster farther than `maxShadowCastingDistance` from the cascade's own camera-frustum-slice bounding sphere (`ShadowSystem.cascadeWorldCenters`/`cascadeWorldRadii`) cannot matter to that cascade regardless of light direction, so it is dropped before the more expensive test. Second, the fitted light-space frustum is the correctness-preserving cull for what actually lands in the shadow map. Camera distance and cascade receiver splits alone are not valid caster-relevance tests for a directional light — an object outside the receiver's camera-depth interval can still project a shadow into it — which is why the distance reject is measured from the cascade's world-space bounds, not the camera. +**Directional caster culling:** Each cascade culls shadow casters in two stages. First, a direction-agnostic world-space distance reject: a caster farther than `maxShadowCastingDistance` from the cascade's own camera-frustum-slice bounding sphere (`ShadowSystem.cascadeWorldCenters`/`cascadeWorldRadii`) cannot matter to that cascade regardless of light direction, so it is dropped before the more expensive test. Second, the fitted light-space frustum is the correctness-preserving cull for what actually lands in the shadow map. Camera distance and cascade receiver splits alone are not valid caster-relevance tests for a directional light — an object outside the receiver's camera-depth interval can still project a shadow into it — which is why the distance reject is measured from the cascade's world-space bounds, not the camera. Before both, a caster that small-object culling leaves out of the main view (its bounding sphere is under the pixel threshold as seen from the camera) is left out of the shadow maps too, for directional, spot and point lights: its shadow would be about as small. **Shadow softness:** CSM sampling uses a centered 16-tap Poisson PCF kernel. Softness is authored in cascade-0 texels, converted to a world-space radius, then converted into each sampled cascade's texels. This keeps the physical filter footprint consistent across cascade transitions. The default runtime softness is `nearRadiusTexels = 2.0`, `farRadiusTexels = 5.0`, with an `xrRadiusScale = 1.35` applied only in stereo XR. Use `setShadowSoftness(_:)` to tune this globally per scene. diff --git a/docs/Architecture/tilebasedstreaming.md b/docs/Architecture/tilebasedstreaming.md index b9ddefb87..0adda05a4 100644 --- a/docs/Architecture/tilebasedstreaming.md +++ b/docs/Architecture/tilebasedstreaming.md @@ -167,7 +167,7 @@ unloaded → parsing → parsed → unloading → unloaded 1. Locates and decodes the manifest JSON (no geometry parsed). If the manifest URL is HTTP/HTTPS, it is downloaded and cached via `RemoteAssetDownloader` before decoding. Tile asset URLs in the manifest are resolved relative to the manifest's base URL, so remote manifests produce remote tile URLs (e.g. `https://cdn.example.com/scene/tiles/tile_0_0.untold`). See [`asset_remote_streaming.md`](asset_remote_streaming.md) for the full download lifecycle. 2. Resets `interiorZone` and `firstRangeTimestamps` on `GeometryStreamingSystem` so stale scene-level state from a previous scene does not bleed into the new one. -3. Registers the supplied root entity with `TiledSceneComponent`, `LocalTransformComponent`, and `ScenegraphComponent`. +3. Marks the supplied root entity with `TiledSceneComponent`. The root keeps the `LocalTransformComponent` and `ScenegraphComponent` that `createEntity()` gave it (they are registered only when missing), so it stays under its parent and keeps its existing children. Its own transform is reset to identity, because tile bounds are world-space values; a warning is logged when that discards a transform or when an ancestor still keeps the root away from identity. 4. Registers one lightweight stub entity per tile inside a single `withWorldMutationGate`, parented under the root entity. Each stub receives: - Identity world transform - `LocalTransformComponent.boundingBox` set to the tile's world-space AABB diff --git a/docs/Architecture/xrRenderingSystem.md b/docs/Architecture/xrRenderingSystem.md index feccb0bac..b4c3059ae 100644 --- a/docs/Architecture/xrRenderingSystem.md +++ b/docs/Architecture/xrRenderingSystem.md @@ -26,7 +26,7 @@ At init time, three things happen in parallel: **Environment lighting provider ownership**: The XR layer observes the runtime rendering lighting mode. When the app sets `setRendering(.environment(.lightingMode(.realWorldEstimate)))`, the XR runtime includes ARKit environment light estimation in the provider set and feeds accepted probe updates into the engine's IBL path. Switching back to `.authoredOnly` or `.staticIBL` disables that provider path. `realWorldLightingContribution` is a rendering multiplier and can change at runtime without restarting ARKit providers. -**Plane monitor** (background Task): A long-running Swift structured concurrency task that consumes the `planeDetection.anchorUpdates` async stream. Every time the system detects, updates, or removes a real-world surface (floor, wall, table, etc.), it maps the ARKit classification to the engine's `RealSurfaceKind` enum and forwards it to `RealSurfacePlaneStore`. Game code queries this store to snap objects to real surfaces. +**Plane monitor** (background Task): A long-running Swift structured concurrency task that consumes the `planeDetection.anchorUpdates` async stream. Every time the system detects, updates, or removes a real-world surface (floor, wall, table, etc.), it maps the ARKit classification to the engine's `RealSurfaceKind` enum and forwards `TrackedPlane` snapshots to `RealSurfacePlaneStore`. Game code uses [`pickRealSurfacePosition`](../API/UsingSpatialInput.md#get-groundplane-hit-position) to query those planes for placement; the API guide covers filters, hit fields, diagnostics, and physical world coordinates under scene-root scaling. **Renderer creation**: `UntoldRenderer.createXR(...)` initializes the Metal device, command queue, G-Buffer textures, pipeline states, and all other GPU resources at the fixed visionOS viewport size (2048 Ɨ 1984 per eye). @@ -201,6 +201,8 @@ The completion handler signals `commandBufferSemaphore` when the GPU finishes, f The snapshot is processed on the next frame's update phase by `spatialGestureRecognizer.updateSpatialInputState()`, which converts raw ray/phase sequences into higher-level gesture events (tap, hold, drag) that game code can query. +Selection rays update from interaction events, not continuously as the user looks around. A completed tap is reported on `.ended`, and an event without a valid ray retains the previous ray. Ray fields, phase, and timestamp can persist between events; game code should gate actions on gesture signals rather than assume those fields describe fresh input. The gaze fields are currently placeholders, and the active camera entity's transform is not synchronized to the ARKit head pose used for rendering. See the [XR input model](../API/UsingSpatialInput.md#xr-input-model) for tap handling, ray lifetime, and camera limitations. + > The bridge is gated on `isSceneReady()` and `!AssetLoadingGate.shared.isLoadingAny`. Input events while loading are discarded to prevent game code from acting on uninitialized entities. --- diff --git a/docs/LearningPaths/ArchvizToVisionPro.md b/docs/LearningPaths/ArchvizToVisionPro.md index 06189c39c..b6a2c03d8 100644 --- a/docs/LearningPaths/ArchvizToVisionPro.md +++ b/docs/LearningPaths/ArchvizToVisionPro.md @@ -122,6 +122,12 @@ This is where the generated project sets up the engine scene. The two important In `init()`, after `configureEngineSystems()`, create an entity and load the archviz model with `setEntityMeshAsync`. +> This walkthrough's `Bedroom` is a single-model export (`Bedroom.untold`), +> which is what `loadSceneAuthored` can bring scene-authored lights/cameras +> in from. A `.blend` scene with more than one independent model exports as +> a `.untoldpack` instead, and `loadSceneAuthored` has no effect on those — +> see [Using the Untold Engine CLI](../API/UsingUntoldEngineCLI.md#multi-model-blend-scenes--untoldpack). + ```swift let entity = createEntity() setEntityName(entityId: entity, name: "Bedroom") diff --git a/docs/LearningPaths/BedroomDigitalTwin.md b/docs/LearningPaths/BedroomDigitalTwin.md index dc3192c45..111b334bf 100644 --- a/docs/LearningPaths/BedroomDigitalTwin.md +++ b/docs/LearningPaths/BedroomDigitalTwin.md @@ -198,6 +198,11 @@ setEntityMeshAsync(entityId: room, filename: "DigitalTwin", withExtension: "unto `setEntityMeshAsync` looks for the asset by name in `GameData`, so the filename is `"DigitalTwin"` and the extension is `"untold"`. +> This assumes `DigitalTwin` exported as a single model. A `.blend` scene +> with more than one independent model exports as a `.untoldpack` instead, +> and `loadSceneAuthored` has no effect on those — see [Using the Untold +> Engine CLI](../API/UsingUntoldEngineCLI.md#multi-model-blend-scenes--untoldpack). + ## Configure XR Input And Rendering Use the same Vision Pro setup pattern as the archviz path: diff --git a/docs/Tutorials/CreateXcodeProjectTutorial.md b/docs/Tutorials/CreateXcodeProjectTutorial.md index 0c2f99aec..e3fd2d5b9 100644 --- a/docs/Tutorials/CreateXcodeProjectTutorial.md +++ b/docs/Tutorials/CreateXcodeProjectTutorial.md @@ -168,6 +168,12 @@ loadSceneAuthored(filename: "Bedroom", withExtension: "untold") { authoredLoaded } ``` +> This only works for a single-model export (`Bedroom.untold`). A `.blend` +> scene with more than one independent model exports as a `.untoldpack` +> instead, which has no scene-level slot for this data — the exporter drops +> it and `loadSceneAuthored` has no effect. See [Using the Untold Engine +> CLI](../API/UsingUntoldEngineCLI.md#multi-model-blend-scenes--untoldpack). + Normal mesh loading only loads geometry and materials. Scene-authored data is a separate step. diff --git a/docs/index.md b/docs/index.md index ba61d779d..d5966b7fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -78,7 +78,7 @@ Clone the repository and launch the Starter Demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.21.0 +git checkout v0.22.0 swift run StarterDemo ``` diff --git a/docs/proposals/ActorsAndLocks.md b/docs/proposals/ActorsAndLocks.md index 1f11ee6b8..7fbe47ae4 100644 --- a/docs/proposals/ActorsAndLocks.md +++ b/docs/proposals/ActorsAndLocks.md @@ -7,7 +7,7 @@ Two questions: 1. May engine code use Swift actors, or are they too slow next to locks? 2. Does the per-frame path use the best lock mechanism? -Everything below can be re-run: the benchmark is `Examples/ConcurrencyBench`, the census is `Tests/UntoldEngineRenderTests/LockCensusTests.swift` (section 6). +Everything below can be re-run with the commands in section 7: the benchmark is `Examples/ConcurrencyBench` (section 2), the census is `Tests/UntoldEngineRenderTests/LockCensusTests.swift` (section 4). ## TL;DR diff --git a/scripts/tests/README.md b/scripts/tests/README.md index a01371194..2fe05a1e4 100644 --- a/scripts/tests/README.md +++ b/scripts/tests/README.md @@ -12,6 +12,10 @@ Current scope: tile bounds, coordinate space conversion, mesh classification, output helpers, and argument parsing. `bpy`/`bmesh`/`mathutils` are stubbed with `MagicMock` so these tests run without Blender installed. +- `test_addon_bridge.py`: what the Blender add-on does with the result of an + export (`untold-blender-addon/untold_exporter/bridge.py`): which files its + texture bake bakes and patches. `bpy` is stubbed while the bridge loads, and + the files are written by the exporter's own writers. Run locally from the repo root: diff --git a/scripts/tests/test_addon_bridge.py b/scripts/tests/test_addon_bridge.py new file mode 100644 index 000000000..03ffa9916 --- /dev/null +++ b/scripts/tests/test_addon_bridge.py @@ -0,0 +1,224 @@ +# Copyright (C) Untold Engine Studios +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +"""Tests for what the Blender add-on does with the result of an export +(scripts/untold-blender-addon/untold_exporter/bridge.py). + +bridge.py imports bpy at module level, so it is loaded here with a stand-in for it. The +Blender side of an export (finding and extracting objects) is left out: the stand-in +exporter hands hand-made nodes to the exporter's own writers, so the files, their names +and their folders are the real ones. +""" + +import contextlib +import importlib.util +import io +import struct +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +SCRIPT_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +# Before bridge.py is loaded: the exporter decides at import whether it runs in Blender. +import texbake as t # noqa: E402 +import untoldexplorer as u # noqa: E402 + +BRIDGE_PATH = SCRIPT_DIR / "untold-blender-addon" / "untold_exporter" / "bridge.py" + + +def _load_bridge(): + """bridge.py as a module of its own, with a stand-in for bpy while it loads.""" + spec = importlib.util.spec_from_file_location("untold_exporter_bridge_under_test", BRIDGE_PATH) + module = importlib.util.module_from_spec(spec) + with mock.patch.dict(sys.modules, {"bpy": mock.MagicMock()}): + spec.loader.exec_module(module) + return module + + +def _textured_node(name: str, texture_source: Path, *, scale: float = 1.0) -> "u.ExportedNode": + """A one-triangle model whose material has the image as its base colour texture.""" + positions = [(0.0, 0.0, 0.0), (scale, 0.0, 0.0), (0.0, scale, 0.0)] + vertices = b"".join( + struct.pack("<3fII", *position, u.pack_normal((0.0, 0.0, 1.0)), u.pack_tangent((1.0, 0.0, 0.0), 1.0)) + + b"\x00" * (u.VERTEX_STRIDE - 20) + for position in positions + ) + bounds = u.aabb_from_points(positions) + texture = u.ExportedTexture( + name=texture_source.name, uri=texture_source.name, width=4, height=4, mip_count=1, source_path=texture_source + ) + material = u.ExportedMaterial( + name="paint", base_color_factor=(1.0, 1.0, 1.0, 1.0), emissive_factor=(0.0, 0.0, 0.0), + normal_scale=1.0, metallic_factor=0.0, roughness_factor=0.5, occlusion_strength=1.0, + alpha_cutoff=0.5, base_color_texture=texture, + ) + rows = u.identity_matrix_rows() + mesh = u.ExportedMesh( + entity_name=name, parent_entity_name=None, mesh_name=name, local_transform_rows=rows, + local_bounds=bounds, world_bounds=bounds, vertices=vertices, + indices=u.pack_index_data([0, 1, 2], u.INDEX_TYPE_UINT16), edge_indices=b"", + vertex_count=3, index_count=3, edge_index_count=0, index_type=u.INDEX_TYPE_UINT16, + material=material, skin_binding=None, + validation_mesh=u.ValidationMesh(name=name, vertex_count=3, index_count=3, positions=positions, + normals=[], tangents=[], uv0=[], indices=[0, 1, 2], edge_indices=[]), + ) + return u.ExportedNode(entity_name=name, parent_entity_name=None, local_transform_rows=rows, + local_bounds=bounds, world_bounds=bounds, mesh=mesh) + + +def _texture_uris(untold_path: Path) -> list[str]: + """The texture URIs a written .untold holds.""" + raw = untold_path.read_bytes() + header = struct.unpack_from(t._UNTOLD_HEADER_FMT, raw, 0) + chunks = [] + for index in range(header[5]): + fields = struct.unpack_from(t._UNTOLD_CHUNK_FMT, raw, t._UNTOLD_HEADER_SIZE + index * t._UNTOLD_CHUNK_ENTRY_SIZE) + chunks.append({ + "chunk_type": fields[0], "compression_type": fields[1], "file_offset": fields[2], + "compressed_size": fields[3], "uncompressed_size": fields[4], "element_count": fields[5], + }) + strings = t._decompress_chunk(raw, next(c for c in chunks if c["chunk_type"] == t._CHUNK_STRING_TABLE)) + table = next(c for c in chunks if c["chunk_type"] == t._CHUNK_TEXTURE_TABLE) + records = t._decompress_chunk(raw, table) + size = struct.calcsize(t._UNTOLD_TEXTURE_FMT) + uris = [] + for index in range(table["element_count"]): + uri_offset = struct.unpack_from(t._UNTOLD_TEXTURE_FMT, records, index * size)[1] + uris.append(strings[uri_offset:strings.index(b"\x00", uri_offset)].decode("utf-8")) + return uris + + +class _TexbakeWithoutEncoder: + """texbake as the bridge uses it, with the ASTC encoder left out: baking a folder + leaves a .utex beside each texture, and patching a file is texbake's own.""" + + def __init__(self) -> None: + self.baked: list[tuple[Path, list[Path] | None]] = [] + self.patched: list[Path] = [] + + def bake_directory(self, directory, quality, keep_temp, progress_callback=None, untold_files=None) -> None: + self.baked.append((directory, list(untold_files) if untold_files is not None else None)) + for source in sorted(directory.iterdir()): + if source.suffix.lower() == ".png": + source.with_suffix(".utex").write_bytes(b"UTEX\x00\x00\x00\x00" + b"\x00" * 16) + + def patch_refs(self, untold_path: Path) -> None: + self.patched.append(untold_path) + t.patch_refs(untold_path) + + +class AddonTextureBakeTests(unittest.TestCase): + """The add-on's "Bake textures" bakes and patches what the export wrote.""" + + def setUp(self) -> None: + self.tmpdir = tempfile.TemporaryDirectory() + self.root = Path(self.tmpdir.name) + self.source = self.root / "source" / "wall.png" + self.source.parent.mkdir() + self.source.write_bytes(b"png bytes") + self.output_dir = self.root / "Models" + self.bridge = _load_bridge() + self.texbake = _TexbakeWithoutEncoder() + + def tearDown(self) -> None: + self.tmpdir.cleanup() + + def _export(self, models: dict[str, list["u.ExportedNode"]], output_name: str) -> dict: + """export_asset on a scene that holds these models.""" + + def export_objects_to_untold_or_pack(export_objects, *, output_path, file_type_name, validate, compress_geometry, + progress_callback, **_): + if len(models) == 1: + return u.write_single_untold_from_nodes( + next(iter(models.values())), exported_lights=[], exported_cameras=[], output_path=output_path, + file_type_name=file_type_name, compress_geometry=compress_geometry, color_grade_lut_path=None, + validate=validate, progress_callback=progress_callback, + ) + return u.write_untold_pack_from_groups( + models, source_asset_name="scene.blend", output_path=output_path, file_type_name=file_type_name, + compress_geometry=compress_geometry, validate=validate, progress_callback=progress_callback, + ) + + exporter = SimpleNamespace( + prepare_export_objects_from_blender_objects=lambda objects: list(objects), + export_objects_to_untold_or_pack=export_objects_to_untold_or_pack, + stage_hdr_assets_for_output=lambda output_dir, source_asset_path: [], + ) + bridge = self.bridge + scene_object = SimpleNamespace(as_pointer=lambda: 1) + with ( + mock.patch.object(bridge, "exporter_module", lambda: exporter), + mock.patch.object(bridge, "texbake_module", lambda: self.texbake), + mock.patch.object(bridge, "scene_export_candidates", lambda context, scope: [scene_object]), + mock.patch.object(bridge, "scene_payload_candidates", lambda context, scope: []), + mock.patch.object(bridge, "source_asset_path_for_export", lambda output_path: self.root / "scene.blend"), + mock.patch.object(u, "bpy", None), + contextlib.redirect_stdout(io.StringIO()), + ): + return bridge.export_asset( + context=None, output_path=self.output_dir / output_name, scope="ALL", file_type_name="tile", + convert_orientation=False, source_orientation="blender-native", validate=False, + compress_geometry=False, bake_textures=True, texture_quality="medium", keep_texture_temp=False, + ) + + def test_one_model_written_under_a_packs_name_is_baked_where_it_was_written(self) -> None: + """A scene with one model is written as a single .untold whatever the output + names. Asked for scene.untoldpack, the bake went on to patch that name, a file + that is not there, and the export failed after it had written scene.untold.""" + result = self._export({"Wall": [_textured_node("Wall", self.source)]}, "scene.untoldpack") + + written = self.output_dir / "scene.untold" + self.assertEqual(result["output_path"], written) + self.assertFalse((self.output_dir / "scene.untoldpack").exists()) + self.assertEqual(result["texture_bake_status"], "baked") + self.assertEqual(self.texbake.baked, [(self.output_dir / "Textures", [written])]) + self.assertEqual(self.texbake.patched, [written]) + self.assertEqual(_texture_uris(written), ["Textures/wall.utex"]) + + def test_a_single_model_named_as_such_is_baked_as_before(self) -> None: + result = self._export({"Wall": [_textured_node("Wall", self.source)]}, "scene.untold") + + written = self.output_dir / "scene.untold" + self.assertEqual(result["texture_bake_status"], "baked") + self.assertEqual(self.texbake.patched, [written]) + self.assertEqual(_texture_uris(written), ["Textures/wall.utex"]) + + def test_a_packs_shared_textures_are_baked_once_and_every_model_patched(self) -> None: + """The models of a pack share one Textures folder beside their own folders. The + bake looked for a folder in each model's own, found none and baked nothing.""" + models = { + "Wall": [_textured_node("Wall", self.source)], + "Tower": [_textured_node("Tower", self.source, scale=2.0)], + } + result = self._export(models, "scene.untold") + + self.assertTrue(result["is_pack"]) + model_paths = list(result["model_paths"]) + self.assertEqual(len(model_paths), 2) + self.assertEqual(result["texture_bake_status"], "baked") + self.assertEqual(self.texbake.baked, [(self.output_dir / "Textures", model_paths)]) + self.assertEqual(self.texbake.patched, model_paths) + for model_path in model_paths: + self.assertEqual(_texture_uris(model_path), ["../Textures/wall.utex"]) + + def test_an_export_without_textures_says_so(self) -> None: + node = _textured_node("Wall", self.source) + bare = u.replace(node, mesh=u.replace(node.mesh, material=u.replace(node.mesh.material, base_color_texture=None))) + result = self._export({"Wall": [bare]}, "scene.untold") + + self.assertEqual(result["texture_bake_status"], "no textures") + self.assertEqual(self.texbake.baked, []) + self.assertEqual(self.texbake.patched, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_texbake.py b/scripts/tests/test_texbake.py index df4bfe334..296affb63 100644 --- a/scripts/tests/test_texbake.py +++ b/scripts/tests/test_texbake.py @@ -1,3 +1,6 @@ +import contextlib +import io +import json import struct import sys import tempfile @@ -134,6 +137,54 @@ def _build_minimal_untold_file(tmpdir: Path) -> Path: return output_path +def _write_untold_with_texture(path: Path, uri: str, flags: int) -> Path: + """A .untold that holds one texture record and the strings it needs: what + _build_flags_map_from_untold_dir reads from a model file.""" + strings = u.StringTableBuilder() + name_offset = strings.add(Path(uri).stem) + uri_offset = strings.add(uri) + texture_writer = u.BinaryWriter() + u.write_texture_record( + texture_writer, + u.TextureRecord( + name_offset=name_offset, uri_offset=uri_offset, + texture_format=0, flags=flags, width=64, height=64, mip_count=1, + ), + ) + chunk_payloads = [ + (u.CHUNK_TYPES["string_table"], strings.data, 0), + (u.CHUNK_TYPES["texture_table"], texture_writer.data, 1), + ] + header_writer = u.BinaryWriter() + u.write_header( + header_writer, + file_type=u.FILE_TYPES["tile"], + chunk_count=len(chunk_payloads), + mesh_count=0, material_count=0, texture_count=1, entity_count=0, + world_bounds=u.AABB((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)), + root_transform_rows=IDENTITY_ROWS, + content_hash=b"\x00" * 32, + ) + body_start = u.align(len(header_writer.data) + len(chunk_payloads) * u.CHUNK_ENTRY_SIZE, u.FILE_ALIGNMENT) + body = bytearray() + chunk_table_writer = u.BinaryWriter() + for chunk_type, payload, count in chunk_payloads: + while len(body) % u.FILE_ALIGNMENT: + body.append(0) + u.write_chunk_entry( + chunk_table_writer, chunk_type=chunk_type, compression_type=u.COMPRESSION_NONE, + file_offset=body_start + len(body), compressed_size=len(payload), uncompressed_size=len(payload), + element_count=count, + ) + body += payload + file_bytes = bytearray(header_writer.data) + chunk_table_writer.data + file_bytes += b"\x00" * (body_start - len(file_bytes)) + file_bytes += body + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(bytes(file_bytes)) + return path + + def _read_string_at(data: bytes, string_table: bytes, offset: int) -> str: end = string_table.index(b"\x00", offset) return string_table[offset:end].decode("utf-8") @@ -193,6 +244,66 @@ def test_height_slot_maps_to_uncompressed_r16unorm(self) -> None: self.assertEqual(t._untold_format_for_config(config), t._UNTOLD_FORMAT_R16_UNORM) +class TexbakePackHintTests(unittest.TestCase): + """A pack's shared Textures folder is baked with the flags its own models give each + texture. An assets folder can hold other exports beside the pack: a texture of + theirs with the same name is not the same texture.""" + + def setUp(self) -> None: + self.tmpdir = tempfile.TemporaryDirectory() + self.root = Path(self.tmpdir.name) + self.textures = self.root / "Textures" + self.textures.mkdir() + # The pack: a shelf placed twice, whose "wall" is a color texture. + self.shelf = _write_untold_with_texture(self.root / "Shelf" / "Shelf.untold", "../Textures/wall.png", u.TEXTURE_FLAG_SRGB) + self.pack = self.root / "Warehouse.untoldpack" + self.pack.write_text(json.dumps({ + "formatVersion": 1, + "sourceAsset": "Warehouse.blend", + "models": [ + {"displayName": "Shelf", "path": "Shelf/Shelf.untold", "transform": IDENTITY_ROWS}, + {"displayName": "Shelf.001", "path": "Shelf/Shelf.untold", "transform": IDENTITY_ROWS}, + ], + })) + # Another export kept in the same assets folder, whose own "wall" is a normal map. + # Its folder comes first by name, and the first file to name a texture wins. + self.other = _write_untold_with_texture(self.root / "Annex" / "Annex.untold", "Textures/wall.png", u.TEXTURE_FLAG_NORMAL_MAP) + + def tearDown(self) -> None: + self.tmpdir.cleanup() + + def test_a_pack_stands_for_the_models_it_lists_each_once(self) -> None: + self.assertEqual(t._untold_files_named([self.pack]), [self.shelf]) + self.assertEqual(t._untold_files_named([self.other, self.pack, self.other]), [self.other, self.shelf]) + + def test_hints_come_from_the_packs_own_models(self) -> None: + # Every .untold under the folder, as a folder was read: the other export's flags win. + every_file = sorted(self.root.rglob("*.untold")) + self.assertEqual( + t._build_flags_map_from_untold_dir(self.textures, every_file), {"wall": u.TEXTURE_FLAG_NORMAL_MAP} + ) + self.assertEqual( + t._build_flags_map_from_untold_dir(self.textures, [self.pack]), {"wall": u.TEXTURE_FLAG_SRGB} + ) + + def test_a_folder_is_not_taken_for_its_models(self) -> None: + self.assertEqual(t._build_flags_map_from_untold_dir(self.textures, [self.root]), {}) + with contextlib.redirect_stderr(io.StringIO()) as errors: + status = t.main(["--dir", str(self.textures), "--untold", str(self.root)]) + self.assertEqual(status, 1) + self.assertIn("not a folder", errors.getvalue()) + + def test_a_pack_that_cannot_be_read_is_reported(self) -> None: + broken = self.root / "Broken.untoldpack" + broken.write_text("{ not json") + half_written = self.root / "HalfWritten.untoldpack" + half_written.write_text(json.dumps({"models": [{"path": "Shelf/Shelf.untold"}, {"displayName": "no path"}]})) + missing = self.root / "Missing.untoldpack" + with contextlib.redirect_stderr(io.StringIO()) as errors: + self.assertEqual(t._untold_files_named([broken, half_written, missing]), []) + self.assertEqual(errors.getvalue().count("warning: could not read the models of"), 3) + + class TexbakeSlotDetectionTests(unittest.TestCase): def test_detect_slot_routes_displacement_keywords_to_height(self) -> None: for name in ("Poliigon_BrickWallThin_11512_Displacement", "wall_height", "height_map", "rock_disp"): diff --git a/scripts/tests/test_untoldexplorer.py b/scripts/tests/test_untoldexplorer.py index 379563bac..86c674c60 100644 --- a/scripts/tests/test_untoldexplorer.py +++ b/scripts/tests/test_untoldexplorer.py @@ -8,6 +8,7 @@ import unittest import zlib from pathlib import Path +from unittest import mock SCRIPT_DIR = Path(__file__).resolve().parents[1] @@ -1014,12 +1015,14 @@ def test_extract_material_reads_height_from_displacement_node(self) -> None: analysis = u.analyze_material(material) self.assertEqual(analysis.classification, u.MATERIAL_GRAPH_SUPPORTED) - def _make_displacement_material(self, *, midlevel: float, name: str = "disp_mat") -> FakeData: - height_tex = _make_image_node("height_map") + def _make_displacement_material( + self, *, midlevel: float, name: str = "disp_mat", scale: float = 0.02, height_source: FakeNode | None = None + ) -> FakeData: + height_tex = height_source or _make_image_node("height_map") height_input = FakeSocket("Height") height_input.link_from(height_tex, "Color") scale_socket = FakeSocket("Scale") - scale_socket.default_value = 0.02 + scale_socket.default_value = scale midlevel_socket = FakeSocket("Midlevel") midlevel_socket.default_value = midlevel displacement_node = FakeNode( @@ -1096,6 +1099,185 @@ def test_extract_material_falls_back_to_bump_height_when_no_displacement(self) - self.assertAlmostEqual(exported.height_remap_min, 0.0) self.assertAlmostEqual(exported.height_remap_max, 1.0) + def _add_bump(self, material: FakeData, *, distance: float, height_source: FakeNode | None = None) -> FakeNode: + """Puts a Bump node on the Normal input of a material's Principled BSDF.""" + height_tex = height_source or _make_image_node("bump_height") + height_input = FakeSocket("Height") + height_input.link_from(height_tex, "Color") + distance_socket = FakeSocket("Distance") + distance_socket.default_value = distance + bump_node = FakeNode("ShaderNodeBump", inputs={"Height": height_input, "Distance": distance_socket}) + bump_node.name = "Bump" + + normal_socket = FakeSocket("Normal") + normal_socket.link_from(bump_node, "Normal") + principled = next(node for node in material.node_tree.nodes if node.bl_idname == "ShaderNodeBsdfPrincipled") + principled.inputs["Normal"] = normal_socket + material.node_tree.nodes.extend([bump_node, height_tex]) + return bump_node + + def _make_bump_material(self, *, distance: float, name: str = "bump_mat", height_source: FakeNode | None = None) -> FakeData: + principled, output = _make_principled_output(None) + principled.inputs["Base Color"].default_value = (1.0, 1.0, 1.0, 1.0) + material = _make_material(name, [output, principled]) + self._add_bump(material, distance=distance, height_source=height_source) + return material + + def _extract(self, material: FakeData) -> "u.ExportedMaterial": + mesh_object = FakeSceneObject("Wall", "MESH", FakeData(materials=[material])) + with tempfile.TemporaryDirectory() as tmpdir: + return u.extract_material(mesh_object, Path(tmpdir) / "asset.untold") + + def test_a_displacement_left_at_blenders_default_scale_is_not_a_parallax_depth(self) -> None: + """Blender leaves a Displacement Scale at 1, a metre, and with its default "Bump + Only" displacement draws the shading of a bump from it. Copied as the engine's + parallax depth, a share of the texture's width, it smeared the leather of a chair + across its seat.""" + material = self._make_displacement_material(midlevel=0.5, scale=1.0, name="West_Elm_tex") + exported = self._extract(material) + + self.assertIsNone(exported.height_texture) + self.assertEqual(exported.height_scale, 0.05) + self.assertEqual(exported.height_remap_max, 1.0) + + findings = u.analyze_material(material).findings + self.assertTrue( + any(finding.node_type == "ShaderNodeDisplacement" and "too deep to be a parallax depth" in finding.reason for finding in findings), + [finding.reason for finding in findings], + ) + + def test_a_bump_left_at_blenders_default_distance_is_not_a_parallax_depth(self) -> None: + material = self._make_bump_material(distance=1.0, name="Plant_Banan") + exported = self._extract(material) + + self.assertIsNone(exported.height_texture) + self.assertEqual(exported.height_scale, 0.05) + + findings = u.analyze_material(material).findings + self.assertTrue( + any(finding.node_type == "ShaderNodeBump" and "height of 1 is the shading of a bump" in finding.reason for finding in findings), + [finding.reason for finding in findings], + ) + + def test_a_height_within_what_parallax_can_show_is_kept(self) -> None: + for scale in (0.004, 0.05, u.MAX_PARALLAX_HEIGHT_SCALE): + with self.subTest(scale=scale): + material = self._make_displacement_material(midlevel=0.5, scale=scale) + exported = self._extract(material) + self.assertIsNotNone(exported.height_texture) + self.assertAlmostEqual(exported.height_scale, scale) + self.assertFalse(any("parallax depth" in finding.reason for finding in u.analyze_material(material).findings)) + + bump = self._extract(self._make_bump_material(distance=0.03)) + self.assertIsNotNone(bump.height_texture) + self.assertAlmostEqual(bump.height_scale, 0.03) + + def _too_deep_findings(self, material: FakeData) -> list[str]: + return [finding.node_type for finding in u.analyze_material(material).findings if "parallax depth" in finding.reason] + + def test_a_bump_beside_an_exported_displacement_is_not_reported_as_too_deep(self) -> None: + """The export reads a Bump node only when the Displacement gives it no height. A + Bump left at Blender's default Distance, there for fine detail beside a + displacement that is exported, was reported as a height left out for its depth.""" + material = self._make_displacement_material(midlevel=0.5, scale=0.02, name="plaster") + self._add_bump(material, distance=1.0) + + exported = self._extract(material) + self.assertEqual(exported.height_texture.name, "height_map.png") + self.assertAlmostEqual(exported.height_scale, 0.02) + self.assertEqual(self._too_deep_findings(material), []) + + def test_a_height_that_is_not_an_image_is_not_reported_as_too_deep(self) -> None: + """A height from a procedural texture is not exported whatever its depth, and the + report says so of the texture. It used to give the depth as the reason.""" + for make in ( + lambda noise: self._make_displacement_material(midlevel=0.5, scale=1.0, name="rock", height_source=noise), + lambda noise: self._make_bump_material(distance=1.0, name="rock", height_source=noise), + ): + noise = FakeNode("ShaderNodeTexNoise") + noise.name = "Noise Texture" + material = make(noise) + with self.subTest(nodes=[node.bl_idname for node in material.node_tree.nodes]): + self.assertIsNone(self._extract(material).height_texture) + findings = u.analyze_material(material).findings + self.assertEqual([finding.node_type for finding in findings if "parallax depth" in finding.reason], []) + self.assertTrue(any(finding.node_type == "ShaderNodeTexNoise" for finding in findings)) + + def test_a_bump_stands_in_for_a_displacement_that_is_too_deep(self) -> None: + """Only the height that is read and left out is reported: here the Displacement, + whose place the Bump's height takes.""" + material = self._make_displacement_material(midlevel=0.5, scale=1.0, name="bark") + self._add_bump(material, distance=0.03) + + exported = self._extract(material) + self.assertEqual(exported.height_texture.name, "bump_height.png") + self.assertAlmostEqual(exported.height_scale, 0.03) + self.assertEqual(exported.height_remap_max, 1.0) + self.assertEqual(self._too_deep_findings(material), ["ShaderNodeDisplacement"]) + + def _displacement_on(self, surface: FakeNode, *, scale: float) -> FakeData: + """A material whose surface is the given shader, with an image as the height of + a Displacement on its Material Output.""" + material = self._make_displacement_material(midlevel=0.5, scale=scale, name="relief") + nodes = material.node_tree.nodes + output = next(node for node in nodes if node.bl_idname == "ShaderNodeOutputMaterial") + nodes.remove(next(node for node in nodes if node.bl_idname == "ShaderNodeBsdfPrincipled")) + surface.name = "Surface shader" + output.inputs["Surface"].link_from(surface, "BSDF") + nodes.append(surface) + material.diffuse_color = (0.8, 0.8, 0.8, 1.0) + return material + + def test_a_displacement_on_a_surface_that_is_not_principled_is_reported_whatever_its_depth(self) -> None: + """Only a material with a Principled BSDF is exported with a height. On any other + surface a Displacement is left out at any depth, and the report says that: at + Blender's default Scale it gave the depth as the reason, which a smaller Scale + would not have cured, and at a smaller Scale it said nothing.""" + def emission() -> FakeNode: + color = FakeSocket("Color") + color.default_value = (1.0, 1.0, 1.0, 1.0) + strength = FakeSocket("Strength") + strength.default_value = 2.0 + return FakeNode("ShaderNodeEmission", inputs={"Color": color, "Strength": strength}) + + surfaces = { + "ShaderNodeBsdfDiffuse": lambda: FakeNode("ShaderNodeBsdfDiffuse"), + "ShaderNodeBsdfGlass": lambda: FakeNode("ShaderNodeBsdfGlass"), + "ShaderNodeEmission": emission, + } + for surface_id, make_surface in surfaces.items(): + for scale in (1.0, 0.02): + with self.subTest(surface=surface_id, scale=scale): + material = self._displacement_on(make_surface(), scale=scale) + self.assertIsNone(self._extract(material).height_texture) + + findings = [finding for finding in u.analyze_material(material).findings if finding.node_type == "ShaderNodeDisplacement"] + self.assertEqual( + [finding.reason for finding in findings], + ["a height is exported only with a Principled BSDF surface; the relief it gives is not exported"], + ) + self.assertEqual(findings[0].category, u.MATERIAL_GRAPH_BAKEABLE) + self.assertEqual(self._too_deep_findings(material), []) + + def test_a_surface_that_is_not_principled_has_no_height_to_report_without_a_displacement(self) -> None: + principled, output = _make_principled_output(None) + diffuse = FakeNode("ShaderNodeBsdfDiffuse") + diffuse.name = "Diffuse BSDF" + output.inputs["Surface"].link_from(diffuse, "BSDF") + material = _make_material("matte", [output, diffuse]) + self.assertEqual(u.material_height(material, Path()), (None, [])) + self.assertFalse(any("relief" in finding.reason for finding in u.analyze_material(material).findings)) + + def test_a_height_whose_depth_is_linked_takes_the_engines_default_depth(self) -> None: + material = self._make_displacement_material(midlevel=0.5, scale=1.0, name="driven") + displacement = next(node for node in material.node_tree.nodes if node.bl_idname == "ShaderNodeDisplacement") + displacement.inputs["Scale"].link_from(FakeNode("ShaderNodeValue"), "Value") + + exported = self._extract(material) + self.assertIsNotNone(exported.height_texture) + self.assertEqual(exported.height_scale, u.DEFAULT_HEIGHT_SCALE) + self.assertEqual(self._too_deep_findings(material), []) + def test_extract_material_without_displacement_or_bump_has_no_height(self) -> None: tex = _make_image_node("original") principled, output = _make_principled_output(tex) @@ -1427,19 +1609,19 @@ def test_textures_with_different_mappings_use_the_most_common_one_and_report_it( findings = u.analyze_material(material).findings self.assertTrue(any("different Mapping transforms" in finding.reason for finding in findings)) - def test_full_invert_marks_the_texture_inverted_and_partial_invert_does_not(self) -> None: + def test_invert_becomes_an_adjustment_at_its_strength(self) -> None: glossiness = _make_image_node("glossiness") roughness = FakeSocket("Roughness") roughness.link_from(_make_invert_node(glossiness, 1.0), "Color") with tempfile.TemporaryDirectory() as tmpdir: texture = u.resolve_texture_from_socket(roughness, Path(tmpdir) / "asset.blend") - self.assertTrue(texture.invert) + self.assertEqual(texture.adjustments, (u.ImageAdjustment("invert"),)) partial = FakeSocket("Roughness") partial.link_from(_make_invert_node(_make_image_node("gloss"), 0.5), "Color") with tempfile.TemporaryDirectory() as tmpdir: texture = u.resolve_texture_from_socket(partial, Path(tmpdir) / "asset.blend") - self.assertFalse(texture.invert) + self.assertEqual(texture.adjustments, (u.ImageAdjustment("invert", fac=0.5),)) def test_full_invert_is_supported_by_material_analysis(self) -> None: glossiness = _make_image_node("glossiness") @@ -1453,7 +1635,7 @@ def test_full_invert_is_supported_by_material_analysis(self) -> None: def test_inverted_texture_stages_apart_from_the_plain_one(self) -> None: plain = u.ExportedTexture(name="gloss.png", uri="gloss.png", width=4, height=4, mip_count=1, source_image_name="gloss") - inverted = u.replace(plain, invert=True) + inverted = u.replace(plain, adjustments=(u.ImageAdjustment("invert"),)) self.assertNotEqual(u.texture_staging_key(plain), u.texture_staging_key(inverted)) context = u.TextureStagingContext() self.assertEqual(u.unique_texture_destination_name(inverted, context, ".png"), "gloss_inverted.png") @@ -1598,6 +1780,420 @@ def test_assets_dir_argument_defaults_to_none(self) -> None: self.assertEqual(u.parse_args(base + ["--assets-dir", "a"]).assets_dir, "a") +def _socket(name: str, value=None, linked_from: tuple[FakeNode, str] | None = None) -> FakeSocket: + socket = FakeSocket(name) + socket.default_value = value + if linked_from is not None: + socket.link_from(*linked_from) + return socket + + +def _color_node(bl_idname: str, source: FakeNode, input_name: str = "Color", **settings) -> FakeNode: + inputs = {input_name: _socket(input_name, linked_from=(source, "Color"))} + for name, value in settings.items(): + inputs[name.replace("_", " ").title() if name != "fac" else "Fac"] = _socket(name, value) + node = FakeNode(bl_idname, inputs=inputs) + node.name = bl_idname + return node + + +class _FakeCurveMapping: + """RGB Curves' mapping: curves R, G, B, C as functions.""" + + def __init__(self, red, green, blue, combined) -> None: + self.curves = [red, green, blue, combined] + + def evaluate(self, curve, position: float) -> float: + return curve(position) + + +try: + import numpy as _np +except ImportError: # the CI image has no numpy; Blender's Python does + _np = None + + +class MaterialColorNodeTests(unittest.TestCase): + """Colour nodes between an image and a socket become image adjustments.""" + + def test_settings_become_adjustments_and_no_op_settings_none(self) -> None: + tex = _make_image_node() + self.assertEqual( + u.image_adjustment_for_node(_color_node("ShaderNodeGamma", tex, Gamma=1.3)), + u.ImageAdjustment("gamma", (1.3,)), + ) + self.assertIsNone(u.image_adjustment_for_node(_color_node("ShaderNodeGamma", tex, Gamma=1.0))) + self.assertEqual( + u.image_adjustment_for_node(_color_node("ShaderNodeBrightContrast", tex, Bright=0.1, Contrast=0.3)), + u.ImageAdjustment("bright_contrast", (0.1, 0.3)), + ) + hsv = FakeNode("ShaderNodeHueSaturation", inputs={ + "Color": _socket("Color", linked_from=(tex, "Color")), + "Hue": _socket("Hue", 1.0), "Saturation": _socket("Saturation", 1.7), + "Value": _socket("Value", 0.4), "Fac": _socket("Fac", 0.815), + }) + self.assertEqual(u.image_adjustment_for_node(hsv), u.ImageAdjustment("hue_saturation", (1.0, 1.7, 0.4), fac=0.815)) + hsv.inputs["Hue"].default_value = 0.5 + hsv.inputs["Saturation"].default_value = 1.0 + hsv.inputs["Value"].default_value = 1.0 + self.assertIsNone(u.image_adjustment_for_node(hsv)) + + def test_a_linked_setting_cannot_be_written_into_the_image(self) -> None: + tex = _make_image_node() + gamma = _color_node("ShaderNodeGamma", tex, Gamma=1.3) + gamma.inputs["Gamma"].link_from(FakeNode("ShaderNodeValue"), "Value") + self.assertIs(u.image_adjustment_for_node(gamma), u.NOT_REPRESENTABLE) + + socket = _socket("Base Color", linked_from=(gamma, "Color")) + with tempfile.TemporaryDirectory() as tmpdir: + texture = u.resolve_texture_from_socket(socket, Path(tmpdir) / "asset.blend") + self.assertEqual(texture.adjustments, (), "the texture still goes through, without the node") + + def test_adjustments_stack_in_node_order(self) -> None: + tex = _make_image_node("paint") + gamma = _color_node("ShaderNodeGamma", tex, Gamma=2.0) + invert = FakeNode("ShaderNodeInvert", inputs={"Color": _socket("Color", linked_from=(gamma, "Color")), "Fac": _socket("Fac", 1.0)}) + socket = _socket("Base Color", linked_from=(invert, "Color")) + with tempfile.TemporaryDirectory() as tmpdir: + texture = u.resolve_texture_from_socket(socket, Path(tmpdir) / "asset.blend") + self.assertEqual(texture.adjustments, (u.ImageAdjustment("gamma", (2.0,)), u.ImageAdjustment("invert"))) + + def test_rgb_curves_apply_the_combined_curve_before_each_channel(self) -> None: + tex = _make_image_node() + curves = _color_node("ShaderNodeRGBCurve", tex, fac=1.0) + curves.mapping = _FakeCurveMapping(lambda x: x * 0.5, lambda x: x, lambda x: x, lambda x: x * x) + adjustment = u.image_adjustment_for_node(curves) + size = u.CURVE_LUT_SIZE + red_table = adjustment.params[:size] + self.assertAlmostEqual(red_table[-1], 0.5) + self.assertAlmostEqual(red_table[size // 2], ((size // 2) / (size - 1)) ** 2 * 0.5) + curves.mapping = _FakeCurveMapping(*([lambda x: x] * 4)) + self.assertIsNone(u.image_adjustment_for_node(curves), "identity curves change nothing") + + def test_color_ramp_traces_its_texture_through_the_color_output_only(self) -> None: + tex = _make_image_node("roughness") + ramp = FakeNode("ShaderNodeValToRGB", inputs={"Fac": _socket("Fac", linked_from=(tex, "Color"))}) + ramp.color_ramp = FakeData(evaluate=lambda x: (x * 0.131 / 0.6 if x < 0.6 else 0.131,) * 3 + (1.0,)) + with tempfile.TemporaryDirectory() as tmpdir: + asset = Path(tmpdir) / "asset.blend" + through_color = u.resolve_texture_from_socket(_socket("Roughness", linked_from=(ramp, "Color")), asset) + through_alpha = u.resolve_texture_from_socket(_socket("Roughness", linked_from=(ramp, "Alpha")), asset) + self.assertEqual(through_color.adjustments[0].kind, "ramp") + self.assertIsNone(through_alpha) + + def test_adjusted_images_stage_apart_and_a_lone_invert_keeps_its_name(self) -> None: + plain = u.ExportedTexture(name="gloss.png", uri="gloss.png", width=4, height=4, mip_count=1, source_image_name="gloss") + gamma = u.replace(plain, adjustments=(u.ImageAdjustment("gamma", (1.3,)),)) + keys = {u.texture_staging_key(texture) for texture in (plain, gamma, u.replace(plain, adjustments=(u.ImageAdjustment("invert"),)))} + self.assertEqual(len(keys), 3) + self.assertEqual(u.adjustments_suffix((u.ImageAdjustment("invert"),)), "_inverted") + self.assertTrue(u.adjustments_suffix(gamma.adjustments).startswith("_adj")) + + def test_color_nodes_on_a_texture_count_as_supported(self) -> None: + tex = _make_image_node() + hsv = FakeNode("ShaderNodeHueSaturation", inputs={ + "Color": _socket("Color", linked_from=(tex, "Color")), + "Hue": _socket("Hue", 1.0), "Saturation": _socket("Saturation", 1.0), + "Value": _socket("Value", 1.0), "Fac": _socket("Fac", 1.0), + }) + hsv.name = "Hue/Saturation/Value" + principled, output = _make_principled_output(hsv) + analysis = u.analyze_material(_make_material("cartel", [output, principled, hsv, tex])) + self.assertEqual(analysis.classification, u.MATERIAL_GRAPH_SUPPORTED) + + @unittest.skipIf(_np is None, "needs numpy (run under Blender's Python)") + def test_pixel_math_matches_the_cycles_formulas(self) -> None: + rgb = _np.array([[0.2, 0.5, 0.8], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + gamma = u.apply_image_adjustments(rgb, (u.ImageAdjustment("gamma", (2.0,)),)) + self.assertTrue(_np.allclose(gamma, rgb ** 2)) + half_invert = u.apply_image_adjustments(rgb, (u.ImageAdjustment("invert", fac=0.5),)) + self.assertTrue(_np.allclose(half_invert, 0.5)) + rotated = u.apply_image_adjustments(rgb[1:2], (u.ImageAdjustment("hue_saturation", (1.0, 1.0, 1.0)),)) + self.assertTrue(_np.allclose(rotated, [[0.0, 1.0, 1.0]]), "hue 1.0 turns red into cyan") + contrast = u.apply_image_adjustments(rgb, (u.ImageAdjustment("bright_contrast", (0.0, 1.0)),)) + self.assertTrue(_np.allclose(contrast, _np.maximum(2.0 * rgb - 0.5, 0.0))) + self.assertTrue(_np.allclose(u.linear_to_srgb(u.srgb_to_linear(rgb)), rgb, atol=1.0e-6)) + + +class MeshMaterialSlotTests(unittest.TestCase): + def test_the_material_of_the_slot_the_faces_use(self) -> None: + metal, sign = FakeData(name="METAL"), FakeData(name="INFO CARTEL") + data = FakeData(materials=[metal, sign], polygons=[FakeData(material_index=1)]) + obj = FakeSceneObject("Cube.004", "MESH", data) + self.assertIs(u.mesh_object_material(obj), sign) + + def test_an_object_linked_slot_wins_over_the_mesh_material(self) -> None: + mesh_material, object_material = FakeData(name="mesh"), FakeData(name="object") + data = FakeData(materials=[mesh_material], polygons=[FakeData(material_index=0)]) + obj = FakeSceneObject("Cube", "MESH", data) + obj.material_slots = [FakeData(material=object_material)] + self.assertIs(u.mesh_object_material(obj), object_material) + + def test_without_faces_the_first_material(self) -> None: + first = FakeData(name="first") + obj = FakeSceneObject("Empty mesh", "MESH", FakeData(materials=[first, FakeData(name="second")])) + self.assertIs(u.mesh_object_material(obj), first) + + +class FacingEvaluationTests(unittest.TestCase): + """Node chains with no texture behind them are exported as seen straight on.""" + + def _mix(self, fac_source, a, b, fac=0.5) -> FakeNode: + fac_socket = _socket("Factor", fac, linked_from=fac_source) + mix = FakeNode("ShaderNodeMix") + mix.data_type = "RGBA" + mix.blend_type = "MIX" + mix.inputs = [fac_socket, a, b] + return mix + + def test_layer_weight_facing_is_zero_and_its_fresnel_the_normal_reflectance(self) -> None: + weight = FakeNode("ShaderNodeLayerWeight", inputs={"Blend": _socket("Blend", 0.5)}) + self.assertEqual(u.evaluate_socket_facing(_socket("Roughness", linked_from=(weight, "Facing"))), 0.0) + fresnel = u.evaluate_socket_facing(_socket("Roughness", linked_from=(weight, "Fresnel"))) + self.assertAlmostEqual(fresnel, ((2.0 - 1.0) / (2.0 + 1.0)) ** 2) + + def test_a_mix_picks_its_side_without_looking_at_the_other(self) -> None: + weight = FakeNode("ShaderNodeLayerWeight", inputs={"Blend": _socket("Blend", 0.5)}) + texture = _make_image_node("scratches") + mix = self._mix((weight, "Facing"), _socket("A", (0.004, 0.004, 0.004, 1.0)), _socket("B", linked_from=(texture, "Color"))) + self.assertEqual(u.evaluate_socket_facing(_socket("Base Color", linked_from=(mix, "Result"))), (0.004, 0.004, 0.004)) + + constant_one = FakeNode("ShaderNodeMix") + constant_one.data_type, constant_one.blend_type = "RGBA", "MIX" + constant_one.inputs = [_socket("Factor", 1.0), _socket("A", linked_from=(texture, "Color")), _socket("B", (0.27, 0.27, 0.27, 1.0))] + self.assertEqual(u.evaluate_socket_facing(_socket("Roughness", linked_from=(constant_one, "Result"))), (0.27, 0.27, 0.27)) + + def test_a_texture_in_the_way_gives_no_value(self) -> None: + texture = _make_image_node("noise") + mix = self._mix(None, _socket("A", linked_from=(texture, "Color")), _socket("B", (1.0, 1.0, 1.0, 1.0)), fac=0.5) + self.assertIsNone(u.evaluate_socket_facing(_socket("Roughness", linked_from=(mix, "Result")))) + + def test_math_and_node_groups(self) -> None: + math = FakeNode("ShaderNodeMath") + math.operation = "MULTIPLY" + math.inputs = [_socket("Value", 0.5), _socket("Value", 0.4)] + group_input = FakeNode("NodeGroupInput") + group_output = FakeNode("NodeGroupOutput", inputs={"Rough": _socket("Rough", linked_from=(group_input, "Scale"))}) + group = FakeNode("ShaderNodeGroup", inputs={"Scale": _socket("Scale", linked_from=(math, "Value"))}) + group.node_tree = FakeData(nodes=[group_input, group_output]) + self.assertAlmostEqual(u.evaluate_socket_facing(_socket("Roughness", linked_from=(group, "Rough"))), 0.2) + + def test_linked_scalar_without_texture_uses_the_evaluated_value(self) -> None: + value = FakeNode("ShaderNodeValue") + value.outputs = [_socket("Value", 0.27)] + socket = _socket("Roughness", 0.5, linked_from=(value, "Value")) + self.assertAlmostEqual(u._scalar_socket_factor(socket, None, default=0.5), 0.27) + + +class MaterialAlphaTests(unittest.TestCase): + """Alpha and glass become the engine's blended alpha mode.""" + + def _glass_mix(self) -> FakeNode: + """The scene's GLASS: back faces transparent, front faces 92.5 % transparent and + 7.5 % a fully transmissive Principled BSDF.""" + principled = FakeNode("ShaderNodeBsdfPrincipled", inputs={"Transmission Weight": _socket("Transmission Weight", 1.0)}) + transparent = FakeNode("ShaderNodeBsdfTransparent") + front = FakeNode("ShaderNodeMixShader") + front.inputs = [_socket("Fac", 0.075), _socket("Shader", linked_from=(transparent, "BSDF")), _socket("Shader", linked_from=(principled, "BSDF"))] + backfacing = FakeNode("ShaderNodeNewGeometry") + outer = FakeNode("ShaderNodeMixShader") + outer.inputs = [_socket("Fac", 0.5, linked_from=(backfacing, "Backfacing")), _socket("Shader", linked_from=(front, "Shader")), _socket("Shader", linked_from=(FakeNode("ShaderNodeBsdfTransparent"), "BSDF"))] + return outer + + def _material_with_surface(self, surface: FakeNode) -> FakeData: + output = FakeNode("ShaderNodeOutputMaterial", inputs={"Surface": _socket("Surface", linked_from=(surface, "Shader"))}) + return _make_material("m", [output, surface]) + + def test_shader_opacity_of_glass_transmission_and_transparent_mixes(self) -> None: + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(self._glass_mix())), 0.075 * u.TRANSMISSION_OPACITY) + tempered = FakeNode("ShaderNodeBsdfPrincipled", inputs={"Transmission Weight": _socket("Transmission Weight", 1.0)}) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(tempered)), u.TRANSMISSION_OPACITY) + plain = FakeNode("ShaderNodeBsdfPrincipled", inputs={"Transmission Weight": _socket("Transmission Weight", 0.0)}) + self.assertEqual(u.surface_opacity(self._material_with_surface(plain)), 1.0) + + def _glass(self, base_color, transmission: float = 1.0, metallic=None, roughness=None) -> FakeNode: + inputs = { + "Base Color": base_color, + "Transmission Weight": _socket("Transmission Weight", transmission), + } + if metallic is not None: + inputs["Metallic"] = metallic + if roughness is not None: + inputs["Roughness"] = roughness + return FakeNode("ShaderNodeBsdfPrincipled", inputs=inputs) + + def test_tinted_glass_is_as_opaque_as_the_light_its_colour_takes(self) -> None: + """All glass used to keep 10 % opacity whatever its colour: the black tempered + glass of an oven door, a black mirror in Blender, came out as clear as a window.""" + clear = self._glass(_socket("Base Color", (1.0, 1.0, 1.0, 1.0))) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(clear)), u.TRANSMISSION_OPACITY) + + black = self._glass(_socket("Base Color", (0.0, 0.0, 0.0, 1.0))) + self.assertEqual(u.principled_transmittance(black), (0.0, False)) + self.assertEqual(u.surface_opacity(self._material_with_surface(black)), 1.0) + + gray = self._glass(_socket("Base Color", (0.5, 0.5, 0.5, 1.0))) + self.assertAlmostEqual(u.principled_transmittance(gray)[0], 0.5) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(gray)), 1.0 - 0.5 * (1.0 - u.TRANSMISSION_OPACITY)) + + # A colour counts by its brightness: green passes more light than blue. + green = u.surface_opacity(self._material_with_surface(self._glass(_socket("Base Color", (0.0, 1.0, 0.0, 1.0))))) + blue = u.surface_opacity(self._material_with_surface(self._glass(_socket("Base Color", (0.0, 0.0, 1.0, 1.0))))) + self.assertLess(green, blue) + self.assertAlmostEqual(green, 1.0 - 0.7152 * (1.0 - u.TRANSMISSION_OPACITY)) + + # Half glass, half surface: the tint takes only from the glass half. + half = self._glass(_socket("Base Color", (0.0, 0.0, 0.0, 1.0)), transmission=0.5) + self.assertEqual(u.surface_opacity(self._material_with_surface(half)), 1.0) + + def test_a_metal_with_transmission_is_not_glass(self) -> None: + """Blender lays the metal over the glass: chrome whose Transmission was left at 1 + (an imported car's, with its paint, tyres and plastics) is as opaque as any + chrome. At 10 % opacity the car was all but invisible.""" + white = _socket("Base Color", (1.0, 1.0, 1.0, 1.0)) + chrome = self._glass(white, metallic=_socket("Metallic", 1.0)) + self.assertEqual(u.principled_transmittance(chrome), (0.0, False)) + self.assertEqual(u.surface_opacity(self._material_with_surface(chrome)), 1.0) + + mostly_metal = self._glass(white, metallic=_socket("Metallic", 0.9)) + self.assertAlmostEqual(u.principled_transmittance(mostly_metal)[0], 0.1) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(mostly_metal)), 1.0 - 0.1 * (1.0 - u.TRANSMISSION_OPACITY)) + + # A metallic value that comes from a texture is not known here: the surface stays glass. + masked = self._glass(white, metallic=_socket("Metallic", 1.0, linked_from=(_make_image_node("metal_mask"), "Color"))) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(masked)), u.TRANSMISSION_OPACITY) + + # Rubber and dark plastics with the same stray transmission: opaque by their colour. + tyre = self._glass(_socket("Base Color", (0.015, 0.017, 0.018, 1.0)), metallic=_socket("Metallic", 0.0)) + self.assertGreater(u.surface_opacity(self._material_with_surface(tyre)), 0.98) + + def test_frosted_glass_is_not_seen_through(self) -> None: + """A rough transmissive surface scatters the light that crosses it. The white + paint of that same car (Transmission 1, roughness 0.785) is milky in Blender; at + 10 % opacity the body was a ghost.""" + white = _socket("Base Color", (1.0, 1.0, 1.0, 1.0)) + polished = self._glass(white, roughness=_socket("Roughness", 0.0)) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(polished)), u.TRANSMISSION_OPACITY) + + frosted = self._glass(white, roughness=_socket("Roughness", 0.785)) + self.assertAlmostEqual(u.principled_transmittance(frosted)[0], 0.215) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(frosted)), 1.0 - 0.215 * (1.0 - u.TRANSMISSION_OPACITY)) + + ground = self._glass(white, roughness=_socket("Roughness", 1.0)) + self.assertEqual(u.surface_opacity(self._material_with_surface(ground)), 1.0) + + # A roughness that comes from a texture is not known here: the glass counts as polished. + scratched = self._glass(white, roughness=_socket("Roughness", 0.9, linked_from=(_make_image_node("scratches"), "Color"))) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(scratched)), u.TRANSMISSION_OPACITY) + + def test_glass_tinted_by_a_texture_or_by_nodes(self) -> None: + stained = self._glass(_socket("Base Color", (1.0, 1.0, 1.0, 1.0), linked_from=(_make_image_node("stained"), "Color"))) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(stained)), u.TRANSMISSION_OPACITY) + + rgb = FakeNode("ShaderNodeRGB") + rgb.outputs = [_socket("Color", (0.0, 0.0, 0.0, 1.0))] + dark = self._glass(_socket("Base Color", (1.0, 1.0, 1.0, 1.0), linked_from=(rgb, "Color"))) + self.assertEqual(u.surface_opacity(self._material_with_surface(dark)), 1.0) + + def test_black_glass_is_exported_opaque_and_reported(self) -> None: + black = self._glass(_socket("Base Color", (0.0, 0.0, 0.0, 1.0))) + black.name = "GLASS BASE" + output = FakeNode("ShaderNodeOutputMaterial", inputs={"Surface": _socket("Surface", linked_from=(black, "BSDF"))}) + material = _make_material("TEMPERED GLASS", [output, black]) + findings = u.analyze_material(material).findings + self.assertTrue(any("exported as an opaque surface" in finding.reason for finding in findings), [finding.reason for finding in findings]) + self.assertFalse(any("blended surface" in finding.reason for finding in findings)) + + clear = self._glass(_socket("Base Color", (1.0, 1.0, 1.0, 1.0))) + clear.name = "Principled BSDF" + output = FakeNode("ShaderNodeOutputMaterial", inputs={"Surface": _socket("Surface", linked_from=(clear, "BSDF"))}) + findings = u.analyze_material(_make_material("window", [output, clear])).findings + self.assertTrue(any("blended surface at 10% opacity" in finding.reason for finding in findings), [finding.reason for finding in findings]) + + def test_the_report_follows_a_transmission_once(self) -> None: + """The report needs the transmission and what is seen through it, which starts + from the transmission: it followed the same socket twice.""" + clear = self._glass(_socket("Base Color", (1.0, 1.0, 1.0, 1.0))) + clear.name = "Principled BSDF" + output = FakeNode("ShaderNodeOutputMaterial", inputs={"Surface": _socket("Surface", linked_from=(clear, "BSDF"))}) + with mock.patch.object(u, "principled_transmission", wraps=u.principled_transmission) as followed: + findings = u.analyze_material(_make_material("window", [output, clear])).findings + self.assertEqual(followed.call_count, 1) + self.assertTrue(any("blended surface at 10% opacity" in finding.reason for finding in findings)) + self.assertEqual(u.principled_transmittance(clear, (0.5, True)), (0.5, True)) + + def test_a_linked_transmission_is_not_mistaken_for_none(self) -> None: + """A Transmission Weight driven by a texture (frosted or masked glass) used to + fall through to the pre-4.0 "Transmission" lookup and read as 0: opaque.""" + mask = _make_image_node("frost_mask") + glass = FakeNode("ShaderNodeBsdfPrincipled", inputs={"Transmission Weight": _socket("Transmission Weight", 1.0, linked_from=(mask, "Color"))}) + self.assertEqual(u.principled_transmission(glass), (1.0, True)) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(glass)), u.TRANSMISSION_OPACITY) + + output = FakeNode("ShaderNodeOutputMaterial", inputs={"Surface": _socket("Surface", linked_from=(glass, "BSDF"))}) + glass.name = "Principled BSDF" + findings = u.analyze_material(_make_material("frosted", [output, glass])).findings + self.assertTrue(any("cannot follow" in finding.reason and "slider value 1.00" in finding.reason for finding in findings)) + + def test_a_transmission_from_constant_node_math_is_evaluated(self) -> None: + value = FakeNode("ShaderNodeValue") + value.outputs = [_socket("Value", 0.5)] + glass = FakeNode("ShaderNodeBsdfPrincipled", inputs={"Transmission Weight": _socket("Transmission Weight", 0.0, linked_from=(value, "Value"))}) + self.assertEqual(u.principled_transmission(glass), (0.5, False)) + self.assertAlmostEqual(u.surface_opacity(self._material_with_surface(glass)), 1.0 - 0.5 * (1.0 - u.TRANSMISSION_OPACITY)) + + def test_the_pre_4_0_transmission_socket_is_still_read(self) -> None: + legacy = FakeNode("ShaderNodeBsdfPrincipled", inputs={"Transmission": _socket("Transmission", 1.0)}) + self.assertEqual(u.principled_transmission(legacy), (1.0, False)) + + def test_a_mix_driven_by_anything_but_backfacing_is_left_opaque(self) -> None: + mix = FakeNode("ShaderNodeMixShader") + mix.inputs = [_socket("Fac", 0.5, linked_from=(FakeNode("ShaderNodeLayerWeight"), "Facing")), _socket("Shader", linked_from=(FakeNode("ShaderNodeBsdfTransparent"), "BSDF")), _socket("Shader", linked_from=(FakeNode("ShaderNodeBsdfPrincipled"), "BSDF"))] + self.assertEqual(u.surface_opacity(self._material_with_surface(mix)), 1.0) + + def _principled_material(self, alpha_socket: FakeSocket, base_color_source: FakeNode | None = None) -> tuple[FakeData, FakeNode]: + principled, output = _make_principled_output(base_color_source) + principled.inputs["Alpha"] = alpha_socket + return _make_material("mat", [output, principled]), principled + + def test_constant_alpha_blends_and_full_alpha_stays_opaque(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + asset = Path(tmpdir) / "asset.blend" + material, _ = self._principled_material(_socket("Alpha", 0.516)) + self.assertEqual(u._material_alpha(material, material.node_tree.nodes[1].inputs["Alpha"], 0.516, None, asset), (0.516, u.MATERIAL_ALPHA_MODE_BLEND, None)) + material, _ = self._principled_material(_socket("Alpha", 1.0)) + self.assertEqual(u._material_alpha(material, material.node_tree.nodes[1].inputs["Alpha"], 1.0, None, asset)[1], u.MATERIAL_ALPHA_MODE_OPAQUE) + + def test_alpha_from_the_base_colour_image_needs_no_extra_texture(self) -> None: + image = _make_image_node("leaves") + with tempfile.TemporaryDirectory() as tmpdir: + asset = Path(tmpdir) / "asset.blend" + base = u.resolve_texture_from_socket(_socket("Base Color", linked_from=(image, "Color")), asset) + alpha_socket = _socket("Alpha", 1.0, linked_from=(image, "Alpha")) + material, _ = self._principled_material(alpha_socket, image) + factor, mode, alpha_texture = u._material_alpha(material, alpha_socket, 1.0, base, asset) + self.assertEqual((factor, mode, alpha_texture), (1.0, u.MATERIAL_ALPHA_MODE_BLEND, None)) + + def test_alpha_from_another_texture_is_kept_for_staging(self) -> None: + mask = _make_image_node("mask") + gamma = _color_node("ShaderNodeGamma", mask, Gamma=1.3) + alpha_socket = _socket("Alpha", 1.0, linked_from=(gamma, "Color")) + material, _ = self._principled_material(alpha_socket) + with tempfile.TemporaryDirectory() as tmpdir: + factor, mode, alpha_texture = u._material_alpha(material, alpha_socket, 1.0, None, Path(tmpdir) / "asset.blend") + self.assertEqual((factor, mode), (1.0, u.MATERIAL_ALPHA_MODE_BLEND)) + self.assertEqual(alpha_texture.adjustments, (u.ImageAdjustment("gamma", (1.3,)),)) + + def test_the_alpha_mode_is_written_in_the_material_flags(self) -> None: + writer = u.BinaryWriter() + u.write_material_record(writer, u.MaterialRecord( + name_offset=0, flags=u.MATERIAL_ALPHA_MODE_BLEND, base_color_factor=(1.0, 1.0, 1.0, 0.5), + emissive_factor=(0.0, 0.0, 0.0), normal_scale=1.0, metallic_factor=0.0, roughness_factor=0.5, + occlusion_strength=1.0, alpha_cutoff=0.5, base_color_texture_index=u.INVALID_INDEX, + )) + self.assertEqual(struct.unpack_from(" "u.ExportedNode": + """A one-triangle node; vertices go through pack_normal etc. like a real export.""" + positions = [(0.0, 0.0, 0.0), (scale, 0.0, 0.0), (0.0, scale, 0.0)] + vertices = b"".join( + struct.pack("<3fII", *position, u.pack_normal((0.0, 0.0, 1.0)), u.pack_tangent((1.0, 0.0, 0.0), 1.0)) + + b"\x00" * (u.VERTEX_STRIDE - 20) + for position in positions + ) + indices = u.pack_index_data([0, 1, 2], u.INDEX_TYPE_UINT16) + bounds = u.aabb_from_points(positions) + material = u.ExportedMaterial( + name=material_name, base_color_factor=(1.0, 1.0, 1.0, 1.0), emissive_factor=(0.0, 0.0, 0.0), + normal_scale=1.0, metallic_factor=0.0, roughness_factor=0.5, occlusion_strength=1.0, + alpha_cutoff=0.5, base_color_texture=None, + ) + rows = u.identity_matrix_rows() + rows[0][3] = root_x + mesh = u.ExportedMesh( + entity_name=name, parent_entity_name=parent, mesh_name=name, local_transform_rows=rows, + local_bounds=bounds, world_bounds=bounds, vertices=vertices, indices=indices, edge_indices=b"", + vertex_count=3, index_count=3, edge_index_count=0, index_type=u.INDEX_TYPE_UINT16, + material=material, skin_binding=None, + validation_mesh=u.ValidationMesh(name=name, vertex_count=3, index_count=3, positions=positions, + normals=[], tangents=[], uv0=[], indices=[0, 1, 2], edge_indices=[]), + ) + return u.ExportedNode(entity_name=name, parent_entity_name=parent, local_transform_rows=rows, + local_bounds=bounds, world_bounds=bounds, mesh=mesh) + + +class PackSharedModelTests(unittest.TestCase): + """Copies of a model in a pack are written once; textures are shared.""" + + def test_copies_have_the_same_signature_wherever_they_stand_and_whatever_their_name(self) -> None: + digests: dict[int, tuple[bytes, str]] = {} + first = u.model_content_signature([_triangle_node("Tree.001", root_x=0.0)], digests) + copy = u.model_content_signature([_triangle_node("Tree.174", root_x=40.0)], digests) + self.assertIsNotNone(first) + self.assertEqual(first, copy) + + def test_copies_of_a_mesh_with_no_material_are_copies_too(self) -> None: + """A mesh with no material got one named after its object, and the material is + part of the signature: Tree.001 and Tree.174, the same prop with no material, + were two models, each written in full.""" + def node(name: str, root_x: float) -> "u.ExportedNode": + mesh_object = FakeSceneObject(name, "MESH", FakeData(materials=[])) + with tempfile.TemporaryDirectory() as tmpdir: + material = u.extract_material(mesh_object, Path(tmpdir) / "asset.untold") + self.assertNotIn(name, material.name) + triangle = _triangle_node(name, root_x=root_x) + return u.replace(triangle, mesh=u.replace(triangle.mesh, material=material)) + + digests: dict[int, tuple[bytes, str]] = {} + first = u.model_content_signature([node("Tree.001", 0.0)], digests) + copy = u.model_content_signature([node("Tree.174", 40.0)], digests) + self.assertIsNotNone(first) + self.assertEqual(first, copy) + + groups = {name: [node(name, root_x)] for name, root_x in (("Tree.001", 0.0), ("Tree.002", 10.0), ("Tree.174", 40.0))} + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "Park.untold" + result = u.write_untold_pack_from_groups( + groups, source_asset_name="Park.blend", output_path=output, file_type_name="tile", + compress_geometry=False, validate=False, progress_callback=None, + ) + self.assertEqual((result["written_model_count"], result["shared_model_count"]), (1, 2)) + + def test_different_geometry_material_or_child_placement_differ(self) -> None: + digests: dict[int, tuple[bytes, str]] = {} + base = u.model_content_signature([_triangle_node("A")], digests) + self.assertNotEqual(base, u.model_content_signature([_triangle_node("A", scale=2.0)], digests)) + self.assertNotEqual(base, u.model_content_signature([_triangle_node("A", material_name="metal")], digests)) + child_here = u.model_content_signature([_triangle_node("Root"), _triangle_node("Leaf", root_x=1.0, parent="Root")], digests) + child_there = u.model_content_signature([_triangle_node("Root"), _triangle_node("Leaf", root_x=2.0, parent="Root")], digests) + self.assertNotEqual(child_here, child_there) + + def test_skinned_models_are_not_compared(self) -> None: + node = _triangle_node("Hero") + skinned = u.replace(node, mesh=u.replace(node.mesh, skin_binding=object())) + self.assertIsNone(u.model_content_signature([skinned], {})) + + def test_a_pack_writes_each_model_once_and_places_every_copy(self) -> None: + groups = { + "Tree.001": [_triangle_node("Tree.001", root_x=0.0)], + "Tree.002": [_triangle_node("Tree.002", root_x=10.0)], + "Bench": [_triangle_node("Bench", scale=3.0)], + } + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "Models" / "Park.untold" + result = u.write_untold_pack_from_groups( + groups, source_asset_name="Park.blend", output_path=output, file_type_name="tile", + compress_geometry=False, validate=False, progress_callback=None, assets_dir=Path(tmpdir) / "Models" / "Park", + ) + manifest = json.loads(output.with_suffix(".untoldpack").read_text()) + by_name = {entry["displayName"]: entry for entry in manifest["models"]} + self.assertEqual((result["written_model_count"], result["shared_model_count"]), (2, 1)) + self.assertEqual(by_name["Tree.002"]["path"], by_name["Tree.001"]["path"]) + self.assertEqual(by_name["Tree.002"]["transform"][0][3], 10.0) + self.assertFalse((Path(tmpdir) / "Models" / "Park" / "Tree_002").exists()) + self.assertTrue((output.parent / by_name["Tree.001"]["path"]).is_file()) + + +class PackOutputNameTests(unittest.TestCase): + """A scene with several models is written as a .untoldpack next to where the single + .untold would be. Named as the output itself, the pack used to be taken for a stale + single-file export and removed right after it was written: the export reported + success and left the models without their manifest.""" + + def setUp(self) -> None: + self.tmpdir = tempfile.TemporaryDirectory() + self.output_dir = Path(self.tmpdir.name) + + def tearDown(self) -> None: + self.tmpdir.cleanup() + + def _write_pack(self, output_name: str) -> dict: + def empty_node(name: str) -> "u.ExportedNode": + node = _make_node_with_normal_map("floor_normal.jpg", name) + return u.replace(node, entity_name=name, mesh=None, material_split_root_name=None) + + with contextlib.redirect_stdout(io.StringIO()): + return u.write_untold_pack_from_groups( + {"Floor": [empty_node("Floor")], "Wall": [empty_node("Wall")]}, + source_asset_name="scene.blend", + output_path=self.output_dir / output_name, + file_type_name="tile", + compress_geometry=False, + validate=False, + progress_callback=None, + ) + + def test_single_file_output_path(self) -> None: + self.assertEqual(u.single_file_output_path(Path("/models/scene.untoldpack")), Path("/models/scene.untold")) + self.assertEqual(u.single_file_output_path(Path("/models/scene.UNTOLDPACK")), Path("/models/scene.untold")) + self.assertEqual(u.single_file_output_path(Path("/models/scene.untold")), Path("/models/scene.untold")) + self.assertEqual(u.single_file_output_path(Path("/models/walk.untoldanim")), Path("/models/walk.untoldanim")) + + def test_a_pack_named_as_the_output_is_kept(self) -> None: + result = self._write_pack("scene.untoldpack") + pack_path = self.output_dir / "scene.untoldpack" + self.assertEqual(result["pack_path"], pack_path) + self.assertTrue(pack_path.is_file(), "the manifest is still there after the export") + self.assertIsNone(result["removed_stale_single_path"]) + self.assertEqual(len(json.loads(pack_path.read_text())["models"]), 2) + + def test_a_stale_single_file_is_removed_under_either_name(self) -> None: + for output_name in ("scene.untold", "scene.untoldpack"): + with self.subTest(output_name=output_name): + stale = self.output_dir / "scene.untold" + stale.write_bytes(b"an earlier single-model export") + result = self._write_pack(output_name) + self.assertEqual(result["removed_stale_single_path"], stale) + self.assertFalse(stale.exists()) + self.assertTrue((self.output_dir / "scene.untoldpack").is_file()) + + class TextureWriteFailureTests(unittest.TestCase): """Regression coverage for an export that stopped half-way and left a 33-byte PNG behind. diff --git a/scripts/texbake.py b/scripts/texbake.py index d91439161..449bbb443 100644 --- a/scripts/texbake.py +++ b/scripts/texbake.py @@ -69,6 +69,7 @@ import argparse import hashlib +import json import math import os import shutil @@ -582,11 +583,36 @@ def bake_texture( # Flags map: read sibling .untold files to get authoritative slot info # ────────────────────────────────────────────── +def _untold_files_named(paths: list[Path]) -> list[Path]: + """The .untold files that `--untold` names, each once and in the order given. + + A .untoldpack stands for the models it lists and for nothing else around it. Its + folder is not searched: an assets folder can hold other exports, and a texture of + theirs that is named like one of the pack's (wall, diffuse, normal) is another + texture, whose flags would be taken for this one's. + """ + files: list[Path] = [] + for path in paths: + path = Path(path) + if path.suffix.lower() != ".untoldpack": + files.append(path) + continue + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + models = [path.parent / str(model["path"]) for model in manifest["models"]] + except (OSError, ValueError, KeyError, TypeError) as error: + print(f"warning: could not read the models of {path}: {error}; its textures are told apart by their names", + file=sys.stderr) + continue + files.extend(models) + return list(dict.fromkeys(files)) + + def _build_flags_map_from_untold_dir(textures_dir: Path, untold_files: list[Path] | None = None) -> dict[str, int]: """Scan .untold files in the parent directory of `textures_dir` (or the given `untold_files`, for a .untold kept outside that folder, see the exporter's - --assets-dir) and build a mapping of {texture_stem_lower → flags} from their - texture records. + --assets-dir, or a .untoldpack for the models of a pack) and build a mapping of + {texture_stem_lower → flags} from their texture records. Used by bake_directory so that textures with opaque names (no slot keywords) are encoded with the correct sRGB/LDR block format. @@ -596,6 +622,8 @@ def _build_flags_map_from_untold_dir(textures_dir: Path, untold_files: list[Path result: dict[str, int] = {} if untold_files is None: untold_files = list(textures_dir.parent.glob("*.untold")) + else: + untold_files = _untold_files_named(untold_files) if not untold_files: return result @@ -1177,9 +1205,10 @@ def main(argv: list[str] | None = None) -> int: action="append", metavar="UNTOLD_FILE", help=( - "With --dir: read texture slot hints from this .untold instead of the .untold files " - "beside the directory (repeatable). Use it when the .untold lives elsewhere, as with " - "the exporter's --assets-dir." + "With --dir: read texture slot hints from this .untold, or from the models a " + ".untoldpack lists, instead of the .untold files beside the directory (repeatable). " + "Use it when the .untold lives elsewhere, as with the exporter's --assets-dir, or " + "for a pack's shared Textures folder." ), ) parser.add_argument( @@ -1233,6 +1262,11 @@ def main(argv: list[str] | None = None) -> int: if not args.dir.is_dir(): print(f"error: --dir '{args.dir}' is not a directory", file=sys.stderr) return 1 + for hint in args.untold or []: + # A folder would mean every .untold under it, the pack's or not. + if hint.is_dir(): + print(f"error: --untold takes a .untold file or a .untoldpack, not a folder: '{hint}'", file=sys.stderr) + return 1 bake_directory(args.dir, args.quality, args.keep_temp, untold_files=args.untold) return 0 diff --git a/scripts/untold-blender-addon/untold_exporter/__init__.py b/scripts/untold-blender-addon/untold_exporter/__init__.py index 3636f2637..6de49f671 100644 --- a/scripts/untold-blender-addon/untold_exporter/__init__.py +++ b/scripts/untold-blender-addon/untold_exporter/__init__.py @@ -219,7 +219,8 @@ def progress(stage: str, done: int, total: int, detail: str) -> None: # .untold per model were written instead of a single output_path. destination = f"{result['pack_path'].name} ({result['model_count']} model(s))" else: - destination = output_path.name + # The file written: a .untold even when the output names a pack. + destination = result["output_path"].name message = ( f"Exported {result['mesh_count']} mesh(es), " f"{result['vertex_count']} vertices to {destination}" @@ -235,7 +236,16 @@ def progress(stage: str, done: int, total: int, detail: str) -> None: skipped_textures = result.get("skipped_textures") or [] if skipped_textures: message += f" | {len(skipped_textures)} texture(s) left out, see the system console" - self.report({"WARNING"} if skipped_textures else {"INFO"}, message) + dropped_scene_payload = result.get("dropped_scene_payload") + if dropped_scene_payload: + # A .untoldpack has no scene-level slot to carry these -- they were + # extracted from the scene and then discarded, same as the CLI's + # own console note (untoldexplorer.py main()). + message += ( + f" | {result.get('light_count', 0)} light(s)/{result.get('camera_count', 0)} camera(s) " + "are scene-level and were not written into any pack model" + ) + self.report({"WARNING"} if (skipped_textures or dropped_scene_payload) else {"INFO"}, message) print(f"[Untold Exporter] {message}", flush=True) for skipped_texture in skipped_textures: print(f"[Untold Exporter] - {skipped_texture}", flush=True) diff --git a/scripts/untold-blender-addon/untold_exporter/bridge.py b/scripts/untold-blender-addon/untold_exporter/bridge.py index 8f41336be..257e5fa1b 100644 --- a/scripts/untold-blender-addon/untold_exporter/bridge.py +++ b/scripts/untold-blender-addon/untold_exporter/bridge.py @@ -201,21 +201,29 @@ def export_asset( result["texture_bake_status"] = "skipped" if bake_textures: - # A pack model's textures are staged relative to its own subfolder, not - # output_path's directory (see write_untold_pack_from_groups) -- bake and - # patch each model independently, same as ExportCommand.swift's --optimize. - untold_paths = result["model_paths"] if result.get("is_pack") else [output_path] - texbake = texbake_module() - baked_any = False + # What the export wrote, which is not always what output_path names: a scene + # with one model is a single .untold even when the output names a pack (see + # single_file_output_path). Its textures are in the Textures folder beside it, + # one folder that the models of a pack share (see write_untold_pack_from_groups): + # bake it once, with what the written files say of each texture, then patch + # every file -- same as ExportCommand.swift's --optimize. + if result.get("is_pack"): + untold_paths = list(result["model_paths"]) + textures_dir = result["pack_path"].parent / "Textures" + else: + untold_paths = [result["output_path"]] + textures_dir = result["output_path"].parent / "Textures" def texture_bake_progress(done: int, total: int, detail: str) -> None: if progress_callback is not None: progress_callback("Bake textures", done, total, detail) - for untold_path in untold_paths: - textures_dir = untold_path.parent / "Textures" - if not textures_dir.is_dir(): - continue + if not textures_dir.is_dir(): + result["texture_bake_status"] = "no textures" + if progress_callback is not None: + progress_callback("Bake textures", 0, 1, "No Textures directory was generated") + else: + texbake = texbake_module() if progress_callback is not None: progress_callback("Bake textures", 0, 1, textures_dir.name) try: @@ -224,19 +232,14 @@ def texture_bake_progress(done: int, total: int, detail: str) -> None: texture_quality, keep_texture_temp, progress_callback=texture_bake_progress, + untold_files=untold_paths, ) - texbake.patch_refs(untold_path) + for untold_path in untold_paths: + texbake.patch_refs(untold_path) except SystemExit as exc: code = exc.code if isinstance(exc.code, int) else 1 if code != 0: raise RuntimeError(f"Texture bake failed with exit code {code}") from exc - baked_any = True - - if not baked_any: - result["texture_bake_status"] = "no textures" - if progress_callback is not None: - progress_callback("Bake textures", 0, 1, "No Textures directory was generated") - else: result["texture_bake_status"] = "baked" if progress_callback is not None: progress_callback("Bake textures", 1, 1, "Baked .utex files and patched .untold references") diff --git a/scripts/untoldexplorer.py b/scripts/untoldexplorer.py index ebb1ca72b..68eb54287 100644 --- a/scripts/untoldexplorer.py +++ b/scripts/untoldexplorer.py @@ -138,6 +138,37 @@ TEXTURE_CHANNEL_B = 2 TEXTURE_CHANNEL_A = 3 UNTOLD_EXPORT_TEMP_OBJECT_PROP = "_untold_export_temp_object" +# The name of the material a mesh with no material of its own is given. It is one name +# for all of them, and not one made from the object's name: the material is part of what +# tells two models apart (see model_content_signature), so a name taken from the object +# made every copy of a prop with no material a model of its own. +DEFAULT_MATERIAL_NAME = "default_material" +# Material alpha modes, the low two bits of a material record's flags (the engine's +# MaterialAlphaMode). +MATERIAL_ALPHA_MODE_OPAQUE = 0 +MATERIAL_ALPHA_MODE_MASK = 1 +MATERIAL_ALPHA_MODE_BLEND = 2 +# The opacity a fully transmissive, clear surface (Principled Transmission Weight 1 with +# a white base colour) keeps when exported: the engine has no transmission, so glass +# becomes a blended surface this opaque, enough to keep its reflections visible. Tinted +# or frosted glass comes out more opaque, by what its colour and its roughness take from +# what is seen through it, and a metal opaque (see principled_transmittance). +TRANSMISSION_OPACITY = 0.1 +# The engine's own parallax depth (the default of its heightScale), for a material with +# no height and for a height whose depth is linked and so has no single value. +DEFAULT_HEIGHT_SCALE = 0.05 +# The deepest relief the engine's parallax occlusion mapping can show, as a share of the +# texture's width (the engine's heightScale). Blender leaves a Displacement Scale and a +# Bump Distance at 1, a metre, and what it draws from them under its default "Bump Only" +# displacement is the shading of a bump, not a hollow a metre deep. Carried over as a +# parallax depth, such a value smears the texture across the surface, so a height with +# a scale above this is left out of the export. +MAX_PARALLAX_HEIGHT_SCALE = 0.2 +# Samples per channel of the lookup tables that carry RGB Curves and ColorRamp nodes. +CURVE_LUT_SIZE = 256 +# Rec. 709 luminance, which Blender uses to turn a colour into a value (a Color output +# linked to a Fac or Alpha input). +LUMINANCE_WEIGHTS = (0.2126, 0.7152, 0.0722) # Records the name of the source object a temporary export object stands in for: # each single-material fragment produced by split_blender_objects_by_material(), and # each mesh made from a curve, surface or text object by convert_curve_objects_to_meshes(). @@ -557,7 +588,7 @@ class MaterialRecord: emissive_texture_index: int = INVALID_INDEX occlusion_texture_index: int = INVALID_INDEX height_texture_index: int = INVALID_INDEX - height_scale: float = 0.05 + height_scale: float = DEFAULT_HEIGHT_SCALE height_midlevel: float = 0.5 height_remap_min: float = 0.0 height_remap_max: float = 1.0 @@ -846,9 +877,32 @@ class ExportedTexture: source_image_name: Optional[str] = None channel: int = TEXTURE_CHANNEL_R texture_format: int = TEXTURE_FORMAT_UNKNOWN - # Set when the texture reaches its socket through an Invert node at full strength - # (e.g. a glossiness map feeding Roughness): staging writes the inverted image. - invert: bool = False + # Per-pixel colour nodes between the image and the socket it feeds (Invert, Gamma, + # Bright/Contrast, Hue/Saturation/Value, RGB Curves, ColorRamp), in the order they + # apply; staging writes the adjusted image (see adjust_staged_image). + adjustments: tuple["ImageAdjustment", ...] = () + # The image is sRGB-encoded (its Blender colour space is not data): the adjustments + # run on linear values, as Blender's shader nodes do. + srgb_source: bool = False + + +@dataclass(frozen=True) +class ImageAdjustment: + """One per-pixel colour operation of a shader node, applied to linear RGB values + exactly as Blender's node does it. + + kind: "invert" (no params), "gamma" (gamma), "bright_contrast" (bright, contrast), + "hue_saturation" (hue, saturation, value), "curves" (three lookup tables of + CURVE_LUT_SIZE samples over [0, 1], for R, G and B) or "ramp" (three tables for + R, G and B, indexed by the luminance of the input). + fac: how much of the result is mixed over the input, for the nodes that have one. + """ + kind: str + params: tuple[float, ...] = () + fac: float = 1.0 + + def key(self) -> str: + return hashlib.sha1(repr((self.kind, self.params, self.fac)).encode("utf-8")).hexdigest()[:8] @dataclass(frozen=True) @@ -884,14 +938,14 @@ class ExportedMaterial: # depth fraction — these are not the same unit and there is no exact conversion without # knowing the mesh's texel density. This value is carried through as a reasonable # starting point, not a precise conversion; expect to retune heightScale after import. - height_scale: float = 0.05 + height_scale: float = DEFAULT_HEIGHT_SCALE # Always the neutral default (0.5 = no additional shift) for Displacement-sourced height — # Blender's Midlevel is NOT copied here. The engine's POM is unidirectional (cannot bulge # outward past the true polygon surface the way Blender's signed displacement-around- # Midlevel can), so heightMidlevel is just an additive shift, not a true zero-reference; # copying Blender's Midlevel into it would not reproduce "neutral gray = no visible depth". - # Blender's Midlevel is used to derive height_remap_max instead — see extract_material's - # Displacement-node detection block. + # Blender's Midlevel is used to derive height_remap_max instead — see + # _displacement_remap_max. height_midlevel: float = 0.5 # Derived from Blender's Displacement Midlevel when present (clamped to (0, 1]): raw values # at/above this clip to "no depth", values below get contrast-stretched into the full depth @@ -900,6 +954,12 @@ class ExportedMaterial: height_remap_max: float = 1.0 roughness_texture_channel: int = TEXTURE_CHANNEL_R metallic_texture_channel: int = TEXTURE_CHANNEL_R + # MATERIAL_ALPHA_MODE_*: blended when the surface is not fully opaque in Blender. + alpha_mode: int = MATERIAL_ALPHA_MODE_OPAQUE + # A texture feeding the Principled Alpha input that is not the base colour + # texture's own alpha. The engine reads alpha from the base colour texture only, + # so staging writes it into that texture's alpha channel (see compose_alpha_texture). + alpha_texture: Optional[ExportedTexture] = None @dataclass(frozen=True) @@ -1809,6 +1869,16 @@ def blender_required() -> None: raise RuntimeError("This exporter must run inside Blender so it can use bpy for USD import and mesh extraction.") +def single_file_output_path(output_path: Path) -> Path: + """The single `.untold` an output path stands for. A scene with several models is + written as a `.untoldpack` of the same name, and a caller may name that pack as the + output: taken as it is, the pack would count as a stale single-file export and be + removed right after it is written.""" + if output_path.suffix.lower() == ".untoldpack": + return output_path.with_suffix(".untold") + return output_path + + def normalize_blender_path(path: str) -> Path: raw_path = path if bpy is not None and path.startswith("//"): @@ -2190,7 +2260,7 @@ def _layer_collection_tree(layer_collection: object) -> Iterable[tuple[object, b stack.append((child, render_disabled)) -def filter_scene_objects_for_export(objects: list[object], *, include_hidden: bool = False) -> list[object]: +def filter_scene_objects_for_export(objects: list[object], *, include_hidden: bool = False, quiet: bool = False) -> list[object]: """Drop the objects of a whole-scene export that Blender itself does not show. Objects that only live in collections excluded from the view layer (the checkbox @@ -2206,7 +2276,10 @@ def filter_scene_objects_for_export(objects: list[object], *, include_hidden: bo if bpy is None: return list(objects) view_layer = bpy.context.view_layer - view_layer_object_ids = {obj.as_pointer() for obj in view_layer.objects} + # Objects removed since the view layer last updated (an export's temporary + # objects) are still listed, as None, until it does. + objects = [obj for obj in objects if obj is not None] + view_layer_object_ids = {obj.as_pointer() for obj in view_layer.objects if obj is not None} collection_render_disabled: dict[int, bool] = {} for layer_collection, render_disabled in _layer_collection_tree(view_layer.layer_collection): pointer = layer_collection.collection.as_pointer() @@ -2232,6 +2305,8 @@ def is_hidden(obj: object) -> bool: hidden_names.append(obj.name) else: kept.append(obj) + if quiet: + return kept if excluded_names: print(f" Skipped {len(excluded_names)} object(s) in collections excluded from the view layer", flush=True) if hidden_names: @@ -2562,6 +2637,14 @@ def resolve_texture_from_socket(input_socket: object, asset_path: Path) -> Optio return _resolve_texture_from_socket(input_socket, asset_path, visited_nodes=set(), channel=TEXTURE_CHANNEL_R) +def _image_is_srgb(image: object) -> bool: + """True when Blender decodes the image from sRGB before shader nodes see it.""" + settings = getattr(image, "colorspace_settings", None) + if settings is None or getattr(settings, "is_data", False): + return False + return "srgb" in str(getattr(settings, "name", "")).lower() + + def _exported_texture_from_image(image: object, asset_path: Path, channel: int = TEXTURE_CHANNEL_R) -> ExportedTexture: """Build the pre-staging ExportedTexture for a Blender image datablock. @@ -2594,6 +2677,7 @@ def _exported_texture_from_image(image: object, asset_path: Path, channel: int = source_path=None, source_image_name=source_image_name, channel=channel, + srgb_source=_image_is_srgb(image), ) raw_path = bpy.path.abspath(filepath, library=getattr(image, "library", None)) if bpy is not None else filepath @@ -2613,6 +2697,7 @@ def _exported_texture_from_image(image: object, asset_path: Path, channel: int = source_path=texture_path, source_image_name=source_image_name, channel=channel, + srgb_source=_image_is_srgb(image), ) @@ -2632,13 +2717,21 @@ def _resolve_texture_from_socket(input_socket: object, asset_path: Path, visited texture_channel = texture_channel_from_socket_name(getattr(source_socket, "name", ""), channel) return _exported_texture_from_image(source_node.image, asset_path, channel=texture_channel) - if source_node.bl_idname == "ShaderNodeInvert": - # At full strength the inversion is written into the staged image (see - # stage_texture_for_output); at any other strength its math is dropped. - resolved = _resolve_texture_from_socket(source_node.inputs.get("Color"), asset_path, visited_nodes, channel) - if resolved is not None and _invert_node_is_full(source_node): - return replace(resolved, invert=not resolved.invert) - return resolved + adjustment_input = _ADJUSTMENT_NODE_INPUTS.get(source_node.bl_idname) + if adjustment_input is not None: + # Written into the staged image (see stage_texture_for_output). A node whose + # settings are themselves linked cannot be: the texture goes through as is and + # material fidelity analysis reports the node, as before; a ColorRamp then + # gives no texture, since its output is not the texture's. + resolved = _resolve_texture_from_socket(source_node.inputs.get(adjustment_input), asset_path, visited_nodes, channel) + if resolved is None: + return None + adjustment = image_adjustment_for_node(source_node, getattr(source_socket, "name", "")) + if adjustment is NOT_REPRESENTABLE: + return None if source_node.bl_idname == "ShaderNodeValToRGB" else resolved + if adjustment is None: + return resolved + return replace(resolved, adjustments=resolved.adjustments + (adjustment,)) if source_node.bl_idname in {"ShaderNodeSeparateColor", "ShaderNodeSeparateRGB"}: texture_channel = texture_channel_from_socket_name(getattr(source_socket, "name", ""), channel) @@ -2651,11 +2744,6 @@ def _resolve_texture_from_socket(input_socket: object, asset_path: Path, visited "ShaderNodeNormalMap": ["Color"], "ShaderNodeRGBToBW": ["Color"], "NodeReroute": ["Input"], - # Color-correction nodes — the texture passes through their Color input. - "ShaderNodeGamma": ["Color"], - "ShaderNodeBrightContrast": ["Color"], - "ShaderNodeHueSaturation": ["Color"], - "ShaderNodeCurveRGB": ["Color"], "ShaderNodeCurveFloat": ["Value"], # Mix nodes — try both color inputs; returns whichever one traces to a texture. "ShaderNodeMixRGB": ["Color1", "Color2"], @@ -2673,9 +2761,113 @@ def _resolve_texture_from_socket(input_socket: object, asset_path: Path, visited return None -def _invert_node_is_full(node: object) -> bool: - """True for an Invert node at full strength (Fac unlinked and 1).""" - return _socket_is_identity(node, "Fac", 1.0) +# The colour nodes written into a staged image, and the input the texture comes in by. +_ADJUSTMENT_NODE_INPUTS = { + "ShaderNodeInvert": "Color", + "ShaderNodeGamma": "Color", + "ShaderNodeBrightContrast": "Color", + "ShaderNodeHueSaturation": "Color", + "ShaderNodeRGBCurve": "Color", + "ShaderNodeCurveRGB": "Color", + "ShaderNodeValToRGB": "Fac", +} + +# image_adjustment_for_node's answer for a node an image adjustment cannot reproduce. +NOT_REPRESENTABLE = object() + + +def _unlinked_value(node: object, socket_name: str, default): + """A setting's value, None when the socket is linked (its value varies per pixel).""" + socket = node.inputs.get(socket_name) if getattr(node, "inputs", None) is not None else None + if socket is None: + return default + if getattr(socket, "is_linked", False): + return None + value = getattr(socket, "default_value", default) + return tuple(float(component) for component in value) if hasattr(value, "__len__") else float(value) + + +def _curve_mapping_tables(node: object) -> Optional[tuple[float, ...]]: + """R, G and B lookup tables of an RGB Curves node: the combined (C) curve first, + then each channel's own curve, as Cycles bakes them.""" + mapping = getattr(node, "mapping", None) + curves = list(getattr(mapping, "curves", [])) + if mapping is None or len(curves) < 4: + return None + initialize = getattr(mapping, "initialize", None) + if callable(initialize): + initialize() + positions = [index / (CURVE_LUT_SIZE - 1) for index in range(CURVE_LUT_SIZE)] + combined = [mapping.evaluate(curves[3], position) for position in positions] + tables: list[float] = [] + for channel in range(3): + tables.extend(float(mapping.evaluate(curves[channel], value)) for value in combined) + return tuple(tables) + + +def _color_ramp_tables(node: object) -> Optional[tuple[float, ...]]: + ramp = getattr(node, "color_ramp", None) + if ramp is None: + return None + samples = [ramp.evaluate(index / (CURVE_LUT_SIZE - 1)) for index in range(CURVE_LUT_SIZE)] + return tuple(float(sample[channel]) for channel in range(3) for sample in samples) + + +def _is_identity_table(tables: tuple[float, ...]) -> bool: + size = len(tables) // 3 + return all( + abs(tables[channel * size + index] - index / (size - 1)) <= 1.0e-4 + for channel in range(3) + for index in range(size) + ) + + +def image_adjustment_for_node(node: object, output_name: str = "Color"): + """The image adjustment a colour node applies to the texture passing through it. + + Returns an ImageAdjustment, None for a node set up to change nothing, or + NOT_REPRESENTABLE when a setting is linked (it varies per pixel) or, for a + ColorRamp, when its Alpha output is the one used. + """ + node_id = node.bl_idname + if node_id == "ShaderNodeInvert": + fac = _unlinked_value(node, "Fac", 1.0) + if fac is None: + return NOT_REPRESENTABLE + return ImageAdjustment("invert", fac=fac) if fac != 0.0 else None + if node_id == "ShaderNodeGamma": + gamma = _unlinked_value(node, "Gamma", 1.0) + if gamma is None: + return NOT_REPRESENTABLE + return ImageAdjustment("gamma", (gamma,)) if gamma != 1.0 else None + if node_id == "ShaderNodeBrightContrast": + bright = _unlinked_value(node, "Bright", 0.0) + contrast = _unlinked_value(node, "Contrast", 0.0) + if bright is None or contrast is None: + return NOT_REPRESENTABLE + return ImageAdjustment("bright_contrast", (bright, contrast)) if (bright, contrast) != (0.0, 0.0) else None + if node_id == "ShaderNodeHueSaturation": + values = [_unlinked_value(node, name, default) for name, default in (("Hue", 0.5), ("Saturation", 1.0), ("Value", 1.0), ("Fac", 1.0))] + if any(value is None for value in values): + return NOT_REPRESENTABLE + hue, saturation, value, fac = values + if fac == 0.0 or (abs(hue - 0.5) < 1.0e-6 and saturation == 1.0 and value == 1.0): + return None + return ImageAdjustment("hue_saturation", (hue, saturation, value), fac=fac) + if node_id in {"ShaderNodeRGBCurve", "ShaderNodeCurveRGB"}: + fac = _unlinked_value(node, "Fac", 1.0) + tables = _curve_mapping_tables(node) + if fac is None or tables is None: + return NOT_REPRESENTABLE + if fac == 0.0 or _is_identity_table(tables): + return None + return ImageAdjustment("curves", tables, fac=fac) + if node_id == "ShaderNodeValToRGB": + tables = _color_ramp_tables(node) + if output_name != "Color" or tables is None: + return NOT_REPRESENTABLE + return ImageAdjustment("ramp", tables) + return NOT_REPRESENTABLE def _linked_source(input_socket: object) -> tuple[Optional[object], Optional[object]]: @@ -2793,9 +2985,26 @@ def material_uv_transform(material: Optional[object]) -> Optional[UVTransform]: return first_by_key[winner] -def _first_material(mesh_object: object) -> Optional[object]: - material_slots = getattr(getattr(mesh_object, "data", None), "materials", []) - return material_slots[0] if material_slots and material_slots[0] is not None else None +def mesh_object_material(mesh_object: object) -> Optional[object]: + """The material the mesh's faces use. Each exported mesh has one (multi-material + objects are split first), but it need not sit in the first slot: an object can use + only its second material, which used to export as the first one. Object-linked + slots are honoured through material_slots.""" + data = getattr(mesh_object, "data", None) + polygons = getattr(data, "polygons", None) + try: + index = int(polygons[0].material_index) if polygons is not None and len(polygons) > 0 else 0 + except (TypeError, IndexError, AttributeError): + index = 0 + slots = getattr(mesh_object, "material_slots", None) + if slots is not None and index < len(slots): + material = getattr(slots[index], "material", None) + if material is not None: + return material + materials = getattr(data, "materials", []) + if materials and index < len(materials) and materials[index] is not None: + return materials[index] + return materials[0] if materials and materials[0] is not None else None def _emission_surface_node(material: object) -> Optional[object]: @@ -2837,9 +3046,9 @@ def _emission_surface_node(material: object) -> Optional[object]: "NodeGroupInput", "NodeGroupOutput", # extract_material reads these directly (Displacement -> height texture/Scale/Midlevel, - # or Bump -> height texture/Distance as a fallback) — see the height/displacement - # detection block. Their own Height/Scale/Midlevel/Distance inputs are still walked and - # classified individually below; only the node type itself is exempted here. + # or Bump -> height texture/Distance as a fallback) — see material_height. Their own + # Height/Scale/Midlevel/Distance inputs are still walked and classified individually + # below; only the node type itself is exempted here. "ShaderNodeDisplacement", "ShaderNodeBump", } @@ -2858,8 +3067,8 @@ def _emission_surface_node(material: object) -> Optional[object]: } _GRAPH_UNBAKEABLE_NODE_IDS = { - "ShaderNodeFresnel": "view-dependent", - "ShaderNodeLayerWeight": "view-dependent", + "ShaderNodeFresnel": "view-dependent; exported as seen straight on", + "ShaderNodeLayerWeight": "view-dependent; exported as seen straight on", "ShaderNodeCameraData": "view-dependent", "ShaderNodeLightPath": "depends on the active render ray", } @@ -2963,8 +3172,13 @@ def _classify_graph_node(node: object, from_socket_name: str) -> Optional[Materi and _is_uv_coordinate_source(*_linked_source(node.inputs.get("Vector"))) ): return None - if node_id == "ShaderNodeInvert" and _invert_node_is_full(node): + if node_id in _ADJUSTMENT_NODE_INPUTS and image_adjustment_for_node(node, from_socket_name) is not NOT_REPRESENTABLE: return None + if node_id in {"ShaderNodeBsdfTransparent", "ShaderNodeMixShader"}: + return MaterialGraphFinding( + node_name, node_id, MATERIAL_GRAPH_BAKEABLE, + "approximated: the surface is blended at the opacity the shaders mix to", + ) if node_id in _GRAPH_TRACED_THROUGH_NODE_IDS or node_id == "ShaderNodeMapping": return MaterialGraphFinding(node_name, node_id, MATERIAL_GRAPH_BAKEABLE, "node math is dropped by the exporter") return MaterialGraphFinding(node_name, node_id, MATERIAL_GRAPH_BAKEABLE, "not evaluated by the exporter") @@ -3093,6 +3307,41 @@ def analyze_material(material: object) -> MaterialGraphAnalysis: emission_name = getattr(emission_node, "name", "") or emission_node.bl_idname findings = [finding for finding in findings if finding.node_name != emission_name] + principled = _principled_bsdf_node(node_tree) + if principled is not None: + transmission, unfollowed = principled_transmission(principled) + if transmission or unfollowed: + opacity = transmission_opacity(principled_transmittance(principled, (transmission, unfollowed))[0]) + if opacity >= 0.995: + reason = "nothing is seen through its transmission (a black base colour, a metal, or a fully rough surface), so it is exported as an opaque surface" + else: + reason = f"transmission is approximated as a blended surface at {opacity:.0%} opacity" + if unfollowed: + reason = ( + "Transmission is driven by a texture or node math the exporter cannot follow; " + f"its slider value {transmission:.2f} is used, so {reason}" + ) + findings.append( + MaterialGraphFinding( + getattr(principled, "name", "") or principled.bl_idname, + principled.bl_idname, + MATERIAL_GRAPH_BAKEABLE, + reason, + ) + ) + + # Where the images are does not matter here, only which heights the export leaves out. + _, heights_left_out = material_height(material, Path()) + for height, why in heights_left_out: + findings.append( + MaterialGraphFinding( + getattr(height.node, "name", "") or height.node.bl_idname, + height.node.bl_idname, + MATERIAL_GRAPH_BAKEABLE, + f"{why}; the relief it gives is not exported", + ) + ) + distinct_uv_transforms = {_uv_transform_key(transform) for transform in _material_image_uv_transforms(material)} if len(distinct_uv_transforms) > 1: findings.append( @@ -3946,8 +4195,19 @@ def write_blender_hdr_image_to_path(image_name: str, destination_path: Path) -> img_settings.file_format, img_settings.exr_codec, img_settings.color_depth = saved_image_settings +def adjustments_suffix(adjustments: tuple[ImageAdjustment, ...]) -> str: + """Tells an adjusted image apart from its source in staging keys and file names: + "_inverted" for a lone Invert (readable, and what earlier exports wrote), else a + short fingerprint of the adjustments.""" + if not adjustments: + return "" + if adjustments == (ImageAdjustment("invert"),): + return "_inverted" + return "_adj" + hashlib.sha1("|".join(adjustment.key() for adjustment in adjustments).encode("utf-8")).hexdigest()[:8] + + def texture_staging_key(texture: ExportedTexture) -> str: - inverted = "|inverted" if texture.invert else "" + inverted = adjustments_suffix(texture.adjustments) if texture.source_path is not None: return f"path:{texture.source_path.expanduser().resolve()}{inverted}" if texture.source_image_name: @@ -3955,27 +4215,115 @@ def texture_staging_key(texture: ExportedTexture) -> str: return f"uri:{texture.uri}{inverted}" -def invert_staged_image(path: Path) -> None: - """Invert the color channels of a staged image in place, leaving alpha alone - (Blender's Invert node inverts color only).""" +def srgb_to_linear(values: "np.ndarray") -> "np.ndarray": + return np.where(values <= 0.04045, values / 12.92, ((values + 0.055) / 1.055) ** 2.4) + + +def linear_to_srgb(values: "np.ndarray") -> "np.ndarray": + values = np.clip(values, 0.0, 1.0) + return np.where(values <= 0.0031308, values * 12.92, 1.055 * np.power(values, 1.0 / 2.4) - 0.055) + + +def _rgb_to_hsv(rgb: "np.ndarray") -> "np.ndarray": + maximum = rgb.max(axis=1) + minimum = rgb.min(axis=1) + delta = maximum - minimum + value = maximum + saturation = np.where(maximum > 0.0, delta / np.where(maximum > 0.0, maximum, 1.0), 0.0) + safe_delta = np.where(delta > 0.0, delta, 1.0) + r, g, b = rgb[:, 0], rgb[:, 1], rgb[:, 2] + hue = np.where( + maximum == r, + (g - b) / safe_delta, + np.where(maximum == g, 2.0 + (b - r) / safe_delta, 4.0 + (r - g) / safe_delta), + ) + hue = np.where(delta > 0.0, (hue / 6.0) % 1.0, 0.0) + return np.stack([hue, saturation, value], axis=1) + + +def _hsv_to_rgb(hsv: "np.ndarray") -> "np.ndarray": + hue, saturation, value = hsv[:, 0], hsv[:, 1], hsv[:, 2] + sector = (hue % 1.0) * 6.0 + index = np.floor(sector).astype(np.int32) % 6 + fraction = sector - np.floor(sector) + p = value * (1.0 - saturation) + q = value * (1.0 - saturation * fraction) + t = value * (1.0 - saturation * (1.0 - fraction)) + choices_r = np.stack([value, q, p, p, t, value], axis=1) + choices_g = np.stack([t, value, value, q, p, p], axis=1) + choices_b = np.stack([p, p, t, value, value, q], axis=1) + rows = np.arange(len(hue)) + return np.stack([choices_r[rows, index], choices_g[rows, index], choices_b[rows, index]], axis=1) + + +def _lookup(table: "np.ndarray", values: "np.ndarray") -> "np.ndarray": + """Linear interpolation into a table sampled evenly over [0, 1].""" + return np.interp(np.clip(values, 0.0, 1.0), np.linspace(0.0, 1.0, len(table)), table) + + +def apply_image_adjustments(rgb: "np.ndarray", adjustments: Iterable[ImageAdjustment]) -> "np.ndarray": + """Run linear RGB values (an N x 3 array) through shader-node adjustments, using the + same formulas as Cycles' nodes (svm_gamma, svm_brightness, svm_hsv, svm_invert, + curves and ramp lookups).""" + result = np.asarray(rgb, dtype=np.float64) + for adjustment in adjustments: + before = result + params = adjustment.params + if adjustment.kind == "invert": + result = 1.0 - result + elif adjustment.kind == "gamma": + result = np.where(result > 0.0, np.power(np.maximum(result, 0.0), params[0]), result) + elif adjustment.kind == "bright_contrast": + bright, contrast = params + result = np.maximum((1.0 + contrast) * result + (bright - contrast * 0.5), 0.0) + elif adjustment.kind == "hue_saturation": + hue, saturation, value = params + hsv = _rgb_to_hsv(result) + hsv[:, 0] = (hsv[:, 0] + hue + 0.5) % 1.0 + hsv[:, 1] = np.clip(hsv[:, 1] * saturation, 0.0, 1.0) + hsv[:, 2] = hsv[:, 2] * value + result = _hsv_to_rgb(hsv) + elif adjustment.kind == "curves": + tables = np.asarray(params, dtype=np.float64).reshape(3, -1) + result = np.stack([_lookup(tables[channel], result[:, channel]) for channel in range(3)], axis=1) + elif adjustment.kind == "ramp": + tables = np.asarray(params, dtype=np.float64).reshape(3, -1) + luminance = result @ np.asarray(LUMINANCE_WEIGHTS) + result = np.stack([_lookup(tables[channel], luminance) for channel in range(3)], axis=1) + else: + raise ValueError(f"Unknown image adjustment: {adjustment.kind}") + if adjustment.fac != 1.0: + result = before + (result - before) * adjustment.fac + if adjustment.kind == "hue_saturation": + result = np.maximum(result, 0.0) + return result + + +def adjust_staged_image(path: Path, adjustments: tuple[ImageAdjustment, ...], *, srgb: bool) -> None: + """Apply shader-node adjustments to the colour channels of a staged image in place, + leaving alpha alone. An sRGB image is decoded to linear first and encoded back, so + the nodes see the values Blender's shader sees.""" blender_required() + if not _HAS_NUMPY: + print(f" Warning: '{path.name}' needs numpy to apply its colour nodes; staged without them.", flush=True) + return image = bpy.data.images.load(str(path), check_existing=False) try: image.colorspace_settings.name = "Non-Color" channels = int(image.channels) - color_channels = 3 if channels >= 3 else 1 - if _HAS_NUMPY: - pixels = np.empty(len(image.pixels), dtype=np.float32) - image.pixels.foreach_get(pixels) - pixels = pixels.reshape(-1, channels) - pixels[:, :color_channels] = 1.0 - pixels[:, :color_channels] - image.pixels.foreach_set(pixels.ravel()) + pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(pixels) + pixels = pixels.reshape(-1, channels) + color = pixels[:, :3] if channels >= 3 else np.repeat(pixels[:, :1], 3, axis=1) + if srgb: + color = srgb_to_linear(color) + color = apply_image_adjustments(color, adjustments) + color = linear_to_srgb(color) if srgb else np.clip(color, 0.0, 1.0) + if channels >= 3: + pixels[:, :3] = color else: - pixels = list(image.pixels) - for start in range(0, len(pixels), channels): - for offset in range(color_channels): - pixels[start + offset] = 1.0 - pixels[start + offset] - image.pixels[:] = pixels + pixels[:, 0] = color @ np.asarray(LUMINANCE_WEIGHTS) + image.pixels.foreach_set(pixels.ravel()) image.filepath_raw = str(path) image.file_format = "PNG" image.save() @@ -4020,8 +4368,7 @@ def unique_texture_destination_name( ) -> str: source_name = texture.source_path.name if texture.source_path is not None else texture.name base = Path(source_name).stem or "texture" - if texture.invert: - base = f"{base}_inverted" + base = f"{base}{adjustments_suffix(texture.adjustments)}" suffix = suffix_override if suffix_override is not None else Path(source_name).suffix candidate = f"{base}{suffix}" if candidate not in context.used_names: @@ -4078,10 +4425,12 @@ def skip(reason: str) -> None: print(f" Warning: {message}", flush=True) context.skipped_textures.append(message) - # Early rejection: file-backed textures with unsupported suffixes (EXR, HDR, …) - # are not part of the engine pipeline. Skip with a warning so the export - # continues without crashing. - if source_path is not None: + # The engine loads no EXR/HDR/Cineon/DPX textures. With Blender at hand the image + # is converted to PNG like any other 16-bit source (see write_blender_image_to_path: + # data maps such as an EXR normal or metallic map keep their values, colour values + # above 1 are clipped); without it the file could only be copied as it is, so the + # texture is skipped with a warning and the export carries on. + if source_path is not None and bpy is None: resolved = source_path.expanduser().resolve() if resolved.suffix.lower() in _UNSUPPORTED_TEXTURE_SUFFIXES: skip( @@ -4099,8 +4448,8 @@ def skip(reason: str) -> None: source_path=existing_destination, ) - # An image fails to write whether or not the texture is then inverted. - source_key = texture_staging_key(replace(texture, invert=False)) + # An image fails to write whether or not colour nodes are then applied to it. + source_key = texture_staging_key(replace(texture, adjustments=())) write_failures = context.write_failures known_failure = write_failures.left_out.get(source_key) if known_failure is not None: @@ -4168,11 +4517,11 @@ def write_image(image_name: str) -> None: skip(str(exc)) return None - if texture.invert: + if texture.adjustments: if bpy is not None: - invert_staged_image(destination_path) + adjust_staged_image(destination_path, texture.adjustments, srgb=texture.srgb_source) else: - print(f" Warning: texture '{texture.name}' feeds an Invert node, which needs Blender to apply; staged as is.", flush=True) + print(f" Warning: texture '{texture.name}' feeds colour nodes, which need Blender to apply; staged as is.", flush=True) context.staged_by_key[staging_key] = destination_path @@ -4366,6 +4715,128 @@ def texture_usage(slot: str, material_name: str, object_name: Optional[str] = No return f"{usage} on object '{object_name}'" if object_name else usage +def _load_image_pixels(path: Path) -> tuple["np.ndarray", int, int]: + """A staged image's pixels as stored (no colour transform): rows of RGBA, plus its size.""" + image = bpy.data.images.load(str(path), check_existing=False) + try: + image.colorspace_settings.name = "Non-Color" + width, height = int(image.size[0]), int(image.size[1]) + channels = int(image.channels) + pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(pixels) + pixels = pixels.reshape(-1, channels) + finally: + bpy.data.images.remove(image) + if channels >= 4: + rgba = pixels[:, :4].copy() + elif channels == 3: + rgba = np.concatenate([pixels, np.ones((len(pixels), 1), dtype=np.float32)], axis=1) + else: + rgba = np.concatenate([np.repeat(pixels[:, :1], 3, axis=1), pixels[:, 1:2] if channels == 2 else np.ones((len(pixels), 1), dtype=np.float32)], axis=1) + return rgba, width, height + + +def _resample_nearest(values: "np.ndarray", width: int, height: int, new_width: int, new_height: int) -> "np.ndarray": + if (width, height) == (new_width, new_height): + return values + columns = np.minimum((np.arange(new_width) * width) // new_width, width - 1) + rows = np.minimum((np.arange(new_height) * height) // new_height, height - 1) + grid = values.reshape(height, width, -1) + return grid[rows][:, columns].reshape(new_width * new_height, -1) + + +def compose_alpha_texture( + base_color_texture: Optional[ExportedTexture], + alpha_texture: ExportedTexture, + output_path: Path, + context: TextureStagingContext, + *, + used_as: Optional[str] = None, +) -> Optional[ExportedTexture]: + """Write the Principled Alpha texture into the alpha channel of the (staged) base + colour texture, or of a white texture when the base colour is a constant, since + the engine reads a material's alpha from its base colour texture only. + + The alpha image is staged on its own first (so its colour nodes are applied) in a + temporary folder, read as a value (its alpha channel when the Alpha output feeds + the socket, else the luminance of its colour, as Blender converts a colour linked + to a value) and resampled to the base colour texture's size. + """ + if bpy is None or not _HAS_NUMPY: + print(f" Warning: the {used_as or 'alpha texture'} needs Blender and numpy to be written; the material stays without it.", flush=True) + return base_color_texture + base_key = texture_staging_key(base_color_texture) if base_color_texture is not None else "white" + key = f"alpha:{base_key}|{texture_staging_key(alpha_texture)}|{alpha_texture.channel}" + existing = context.staged_by_key.get(key) + if existing is not None: + return ExportedTexture( + name=existing.name, + uri=relative_asset_uri(existing, output_path.parent), + width=base_color_texture.width if base_color_texture is not None else alpha_texture.width, + height=base_color_texture.height if base_color_texture is not None else alpha_texture.height, + mip_count=1, + source_path=existing, + srgb_source=True, + ) + + with tempfile.TemporaryDirectory() as scratch: + scratch_context = TextureStagingContext( + context.skipped_textures, context.write_failures, assets_dir=Path(scratch) + ) + staged_alpha = stage_texture_for_output(alpha_texture, Path(scratch) / "alpha.untold", scratch_context, used_as=used_as) + if staged_alpha is None or staged_alpha.source_path is None: + return base_color_texture + alpha_rgba, alpha_width, alpha_height = _load_image_pixels(staged_alpha.source_path) + + if alpha_texture.channel == TEXTURE_CHANNEL_A: + alpha_values = alpha_rgba[:, 3:4] + elif alpha_texture.channel in (TEXTURE_CHANNEL_G, TEXTURE_CHANNEL_B): + alpha_values = alpha_rgba[:, alpha_texture.channel:alpha_texture.channel + 1] + else: + # The staged image holds encoded values; Blender converts the linear colour. + color = srgb_to_linear(alpha_rgba[:, :3]) if alpha_texture.srgb_source else alpha_rgba[:, :3] + alpha_values = (color @ np.asarray(LUMINANCE_WEIGHTS, dtype=np.float32)).reshape(-1, 1) + + if base_color_texture is not None and base_color_texture.source_path is not None: + rgba, width, height = _load_image_pixels(base_color_texture.source_path) + stem = Path(base_color_texture.name).stem or "base_color" + else: + width, height = alpha_width, alpha_height + rgba = np.ones((width * height, 4), dtype=np.float32) + stem = f"{Path(alpha_texture.name).stem or 'alpha'}_white" + rgba[:, 3:4] = np.clip(_resample_nearest(alpha_values, alpha_width, alpha_height, width, height), 0.0, 1.0) + + texture_dir = (context.assets_dir or output_path.parent) / "Textures" + texture_dir.mkdir(parents=True, exist_ok=True) + fingerprint = hashlib.sha1(key.encode("utf-8")).hexdigest()[:8] + destination_name = unique_texture_destination_name( + ExportedTexture(name=f"{stem}_alpha_{fingerprint}.png", uri="", width=width, height=height, mip_count=1), + context, + ".png", + ) + destination_path = texture_dir / destination_name + image = bpy.data.images.new(destination_path.stem, width, height, alpha=True) + try: + image.colorspace_settings.name = "Non-Color" + image.alpha_mode = "STRAIGHT" + image.pixels.foreach_set(rgba.ravel()) + image.filepath_raw = str(destination_path) + image.file_format = "PNG" + image.save() + finally: + bpy.data.images.remove(image) + context.staged_by_key[key] = destination_path + return ExportedTexture( + name=destination_name, + uri=relative_asset_uri(destination_path, output_path.parent), + width=width, + height=height, + mip_count=1, + source_path=destination_path, + srgb_source=True, + ) + + def stage_material_for_output( material: ExportedMaterial, output_path: Path, @@ -4384,9 +4855,19 @@ def stage(texture: Optional[ExportedTexture], slot: str, preserve_precision: boo used_as=texture_usage(slot, material.name, object_name), ) + base_color_texture = stage(material.base_color_texture, "base color") + if material.alpha_texture is not None: + base_color_texture = compose_alpha_texture( + base_color_texture, + material.alpha_texture, + output_path, + context, + used_as=texture_usage("alpha", material.name, object_name), + ) return replace( material, - base_color_texture=stage(material.base_color_texture, "base color"), + alpha_texture=None, + base_color_texture=base_color_texture, normal_texture=stage(material.normal_texture, "normal", preserve_precision=True), metallic_texture=stage(material.metallic_texture, "metallic"), roughness_texture=stage(material.roughness_texture, "roughness"), @@ -4421,16 +4902,22 @@ def stage_nodes_for_output( skipped_textures: Optional[list[str]] = None, write_failures: Optional[TextureWriteFailures] = None, assets_dir: Optional[Path] = None, + context: Optional[TextureStagingContext] = None, ) -> list[ExportedNode]: """Stage every node's textures next to output_path, or in assets_dir when given. + context: staging shared with other files of the same export (the models of a + pack), so a texture they have in common is staged once; it then also decides where + Textures/ goes, in place of skipped_textures, write_failures and assets_dir. + skipped_textures: a list that receives one line per texture that had to be left out, so the caller can report them together once the export is done. write_failures: what the export already knows about textures Blender cannot write. An export that stages several models or tiles hands the same one to each of them. """ - context = TextureStagingContext(skipped_textures, write_failures, assets_dir) + if context is None: + context = TextureStagingContext(skipped_textures, write_failures, assets_dir) staged_nodes: list[ExportedNode] = [] total = len(exported_nodes) for i, exported_node in enumerate(exported_nodes, 1): @@ -4489,6 +4976,161 @@ def _detect_occlusion_texture(material: object, asset_path: Path) -> Optional[Ex return None +def _as_color(value) -> tuple[float, float, float]: + if isinstance(value, tuple): + return (float(value[0]), float(value[1]), float(value[2])) if len(value) >= 3 else (float(value[0]),) * 3 + return (float(value),) * 3 + + +def _as_scalar(value) -> float: + """A value as Blender converts it for a float socket: a colour becomes its luminance.""" + if isinstance(value, tuple): + color = _as_color(value) + return sum(component * weight for component, weight in zip(color, LUMINANCE_WEIGHTS)) + return float(value) + + +def _socket_default(socket: object): + value = getattr(socket, "default_value", None) + if value is None: + return None + return tuple(float(component) for component in value) if hasattr(value, "__len__") else float(value) + + +def _enabled_inputs(node: object) -> list: + return [socket for socket in getattr(node, "inputs", []) if getattr(socket, "enabled", True)] + + +def _mix_colors(blend_type: str, fac: float, a: tuple, b: tuple) -> Optional[tuple]: + if blend_type == "MIX": + return tuple(x + (y - x) * fac for x, y in zip(a, b)) + if blend_type == "MULTIPLY": + return tuple(x * (1.0 - fac + fac * y) for x, y in zip(a, b)) + if blend_type == "ADD": + return tuple(x + fac * y for x, y in zip(a, b)) + if blend_type == "SUBTRACT": + return tuple(x - fac * y for x, y in zip(a, b)) + if blend_type == "SCREEN": + return tuple(1.0 - (1.0 - fac + fac * (1.0 - y)) * (1.0 - x) for x, y in zip(a, b)) + return None + + +_MATH_OPERATIONS = { + "ADD": lambda a, b: a + b, + "SUBTRACT": lambda a, b: a - b, + "MULTIPLY": lambda a, b: a * b, + "DIVIDE": lambda a, b: a / b if b != 0.0 else 0.0, + "POWER": lambda a, b: a ** b if a > 0.0 or float(b).is_integer() else 0.0, + "MINIMUM": min, + "MAXIMUM": max, + "GREATER_THAN": lambda a, b: 1.0 if a > b else 0.0, + "LESS_THAN": lambda a, b: 1.0 if a < b else 0.0, + "ABSOLUTE": lambda a, b: abs(a), +} + + +def evaluate_socket_facing(socket: object, _groups: tuple = (), _depth: int = 0): + """The value a shader node chain gives an input socket for a surface seen straight + on: a float or an RGB(A) tuple, or None when the chain holds something that is not + a constant there (an image or procedural texture, an unknown node). + + View-dependent nodes take their straight-on value (Layer Weight Facing 0, Fresnel + the normal-incidence reflectance), so a material built on them exports what it + shows facing the camera; the engine's own Fresnel then brightens its edges. A Mix + whose factor is 0 or 1 never looks at the side it ignores. + """ + if socket is None or _depth > 64: + return None + if not getattr(socket, "is_linked", False): + return _socket_default(socket) + link = socket.links[0] + return _evaluate_node_output(link.from_node, getattr(getattr(link, "from_socket", None), "name", ""), _groups, _depth + 1) + + +def _evaluate_node_output(node: object, output_name: str, groups: tuple, depth: int): + node_id = node.bl_idname + inputs = _enabled_inputs(node) + + def value_of(socket): + return evaluate_socket_facing(socket, groups, depth) + + if node_id == "NodeReroute": + return value_of(node.inputs.get("Input") if hasattr(node.inputs, "get") else inputs[0]) + if node_id in {"ShaderNodeValue", "ShaderNodeRGB"}: + outputs = list(getattr(node, "outputs", [])) + return _socket_default(outputs[0]) if outputs else None + if node_id == "ShaderNodeLayerWeight": + if output_name == "Facing": + return 0.0 + blend = _unlinked_value(node, "Blend", 0.5) + if blend is None: + return None + eta = 1.0 / max(1.0 - min(max(blend, 0.0), 0.99999), 1.0e-5) + return ((eta - 1.0) / (eta + 1.0)) ** 2 + if node_id == "ShaderNodeFresnel": + ior = _unlinked_value(node, "IOR", 1.45) + return None if ior is None else ((ior - 1.0) / (ior + 1.0)) ** 2 + if node_id in {"ShaderNodeMix", "ShaderNodeMixRGB"}: + if len(inputs) < 3: + return None + fac = value_of(inputs[0]) + if fac is None: + return None + fac = _as_scalar(fac) + if node_id == "ShaderNodeMix" and getattr(node, "data_type", "RGBA") != "RGBA": + if getattr(node, "data_type", "") != "FLOAT": + return None + fac = min(max(fac, 0.0), 1.0) if getattr(node, "clamp_factor", True) else fac + a = value_of(inputs[1]) if fac < 1.0 else 0.0 + b = value_of(inputs[2]) if fac > 0.0 else 0.0 + if a is None or b is None: + return None + return _as_scalar(a) + (_as_scalar(b) - _as_scalar(a)) * fac + fac = min(max(fac, 0.0), 1.0) if getattr(node, "clamp_factor", True) else fac + blend_type = getattr(node, "blend_type", "MIX") + a = value_of(inputs[1]) if not (blend_type == "MIX" and fac >= 1.0) else (0.0, 0.0, 0.0) + b = value_of(inputs[2]) if fac > 0.0 else (0.0, 0.0, 0.0) + if a is None or b is None: + return None + mixed = _mix_colors(blend_type, fac, _as_color(a), _as_color(b)) + if mixed is None: + return None + if getattr(node, "clamp_result", False) or getattr(node, "use_clamp", False): + mixed = tuple(min(max(component, 0.0), 1.0) for component in mixed) + return mixed + if node_id == "ShaderNodeMath": + operation = _MATH_OPERATIONS.get(getattr(node, "operation", "")) + if operation is None or not inputs: + return None + a = value_of(inputs[0]) + b = value_of(inputs[1]) if len(inputs) > 1 else 0.0 + if a is None or b is None: + return None + result = operation(_as_scalar(a), _as_scalar(b)) + return min(max(result, 0.0), 1.0) if getattr(node, "use_clamp", False) else result + if node_id in _ADJUSTMENT_NODE_INPUTS: + source = value_of(node.inputs.get(_ADJUSTMENT_NODE_INPUTS[node_id])) + adjustment = image_adjustment_for_node(node, output_name) + if source is None or adjustment is NOT_REPRESENTABLE or not _HAS_NUMPY: + return None + color = _as_color(source) + if adjustment is None: + return color + adjusted = apply_image_adjustments(np.asarray([color], dtype=np.float64), (adjustment,))[0] + return tuple(float(component) for component in adjusted) + if node_id == "ShaderNodeGroup": + tree = getattr(node, "node_tree", None) + group_output = _group_output_node(tree) if tree is not None else None + if group_output is None: + return None + inner = group_output.inputs.get(output_name) if hasattr(group_output.inputs, "get") else None + return evaluate_socket_facing(inner, groups + (node,), depth) if inner is not None else None + if node_id == "NodeGroupInput" and groups: + outer = groups[-1].inputs.get(output_name) if hasattr(groups[-1].inputs, "get") else None + return evaluate_socket_facing(outer, groups[:-1], depth) if outer is not None else None + return None + + def _scalar_socket_factor(input_socket: object, texture: Optional[ExportedTexture], default: float) -> float: """Factor exported for a scalar Principled socket (Metallic, Roughness). @@ -4506,15 +5148,294 @@ def _scalar_socket_factor(input_socket: object, texture: Optional[ExportedTextur return default if texture is not None: return 1.0 + if getattr(input_socket, "is_linked", False): + # No texture behind it: the chain's value seen straight on, if it has one. + value = evaluate_socket_facing(input_socket) + if value is not None: + return min(max(_as_scalar(value), 0.0), 1.0) return float(input_socket.default_value) +def principled_transmission(node: object) -> tuple[float, bool]: + """A Principled BSDF's transmission in [0, 1], and whether it could not be followed. + + The socket is "Transmission Weight" from Blender 4.0 and "Transmission" before; + the old name is looked up only when the new one is absent, so a linked "Transmission + Weight" is not mistaken for a missing one (which read as no transmission at all). + A linked socket gets the value its node chain has seen straight on (see + evaluate_socket_facing). When a texture is in the way the slider value stands in, + as for the other scalar inputs (see _scalar_socket_factor), and the second value is + True so material fidelity analysis reports it. + """ + inputs = getattr(node, "inputs", None) + socket = None + if inputs is not None: + socket = inputs.get("Transmission Weight") + if socket is None: + socket = inputs.get("Transmission") + if socket is None: + return 0.0, False + unfollowed = False + if getattr(socket, "is_linked", False): + value = evaluate_socket_facing(socket) + if value is not None: + return min(max(_as_scalar(value), 0.0), 1.0), False + unfollowed = True + value = getattr(socket, "default_value", 0.0) + try: + transmission = float(value) + except (TypeError, ValueError): + transmission = 0.0 + return min(max(transmission, 0.0), 1.0), unfollowed + + +def principled_transmittance(node: object, followed: Optional[tuple[float, bool]] = None) -> tuple[float, bool]: + """How much of what is behind it a Principled BSDF lets one see, in [0, 1], and + whether its transmission could not be followed (see principled_transmission, whose + answer for the node a caller that already has it passes as `followed`). + + Transmission says how much of the surface is glass. Three things then take from + what is seen through it: + + - The base colour tints the light that crosses it: clear glass (a white base + colour) lets all of it through and black glass none, which is why black glass + looks like a black mirror and not like a window. The tint counts by its brightness. + - Blender lays the metal over the glass, so the metallic share of a surface lets + nothing through whatever its transmission says. + - A rough surface scatters what crosses it: frosted glass glows with the light + behind it and shows nothing of what is there. + + A base colour, a metallic value or a roughness the exporter cannot follow to a + constant (a texture) counts as clear, as no metal and as polished. + """ + transmission, unfollowed = followed if followed is not None else principled_transmission(node) + if transmission <= 0.0: + return 0.0, unfollowed + inputs = getattr(node, "inputs", None) + + def constant(name: str, default: float) -> float: + socket = inputs.get(name) if inputs is not None else None + value = evaluate_socket_facing(socket) if socket is not None else None + return default if value is None else min(max(_as_scalar(value), 0.0), 1.0) + + tint = constant("Base Color", 1.0) + metallic = constant("Metallic", 0.0) + roughness = constant("Roughness", 0.0) + return transmission * (1.0 - metallic) * tint * (1.0 - roughness), unfollowed + + +def transmission_opacity(transmittance: float) -> float: + """The opacity of the blended surface that stands in for a transmissive one.""" + return 1.0 - transmittance * (1.0 - TRANSMISSION_OPACITY) + + +def _shader_opacity(node: Optional[object]) -> Optional[float]: + """How much of the surface a shader covers: 1 for a BSDF, 0 for Transparent BSDF, + less for a transmissive Principled BSDF (see principled_transmittance), mixed by a + Mix Shader's factor. A Mix Shader driven by Geometry > Backfacing takes the + front-face side. None for anything else (a linked factor, Add Shader, ...).""" + if node is None: + return None + node_id = node.bl_idname + if node_id == "ShaderNodeBsdfTransparent": + return 0.0 + if node_id == "ShaderNodeBsdfPrincipled": + transmittance, _ = principled_transmittance(node) + return transmission_opacity(transmittance) + if node_id in {"ShaderNodeBsdfDiffuse", "ShaderNodeBsdfGlossy", "ShaderNodeEmission"}: + return 1.0 + if node_id == "ShaderNodeMixShader": + inputs = list(getattr(node, "inputs", [])) + if len(inputs) < 3: + return None + fac_socket = inputs[0] + if getattr(fac_socket, "is_linked", False): + source, source_socket = _linked_source(fac_socket) + if source is None or source.bl_idname != "ShaderNodeNewGeometry" or getattr(source_socket, "name", "") != "Backfacing": + return None + fac = 0.0 + else: + fac = float(getattr(fac_socket, "default_value", 0.5)) + first = _shader_opacity(_linked_source(inputs[1])[0]) + second = _shader_opacity(_linked_source(inputs[2])[0]) + if first is None or second is None: + return None + return (1.0 - fac) * first + fac * second + return None + + +def surface_opacity(material: object) -> float: + """The opacity the material's surface shader gives (see _shader_opacity); 1 when + the shader tree is not one it can read.""" + node_tree = getattr(material, "node_tree", None) + output = _material_output_node(node_tree) if node_tree is not None else None + if output is None: + return 1.0 + opacity = _shader_opacity(_linked_source(output.inputs.get("Surface"))[0]) + return 1.0 if opacity is None else min(max(opacity, 0.0), 1.0) + + +def _same_image(first: ExportedTexture, second: ExportedTexture) -> bool: + return replace(first, channel=TEXTURE_CHANNEL_R, adjustments=()) == replace(second, channel=TEXTURE_CHANNEL_R, adjustments=()) + + +def _material_alpha( + material: object, + alpha_input: Optional[object], + alpha: float, + base_color_texture: Optional[ExportedTexture], + asset_path: Path, +) -> tuple[float, int, Optional[ExportedTexture]]: + """The base colour factor's alpha, the alpha mode and the texture to write into the + base colour texture's alpha channel (None when there is none, or when the Alpha + input already reads the base colour image's own alpha). + + The engine multiplies the base colour texture's alpha by the factor's alpha, and + blends only materials flagged MATERIAL_ALPHA_MODE_BLEND: before this every + material was flagged opaque, so Alpha and glass rendered solid. + """ + opacity = surface_opacity(material) + alpha_texture = None + uses_base_alpha = False + if alpha_input is not None and getattr(alpha_input, "is_linked", False): + alpha_texture = resolve_texture_from_socket(alpha_input, asset_path) + if ( + alpha_texture is not None + and base_color_texture is not None + and alpha_texture.channel == TEXTURE_CHANNEL_A + and not alpha_texture.adjustments + and _same_image(alpha_texture, base_color_texture) + ): + uses_base_alpha = True + alpha_texture = None + factor_alpha = alpha * opacity + blended = alpha_texture is not None or uses_base_alpha or factor_alpha < 1.0 - 1.0e-4 + return factor_alpha, MATERIAL_ALPHA_MODE_BLEND if blended else MATERIAL_ALPHA_MODE_OPAQUE, alpha_texture + + +@dataclass(frozen=True) +class MaterialHeight: + """A height a material's relief is read from: the image behind the Height input of a + Displacement or a Bump node, and how deep that node says the relief is.""" + + node: object + texture: ExportedTexture + # The node's Scale (Displacement) or Distance (Bump); DEFAULT_HEIGHT_SCALE when that + # input is linked. Blender's value is a distance in the scene and the engine's + # heightScale a share of the texture's width: it is carried over as a starting + # point, not converted (see ExportedMaterial.height_scale). + scale: float + # Texture values at or above this count as no depth (see _displacement_remap_max). + remap_max: float = 1.0 + + @property + def too_deep(self) -> bool: + """The shading of a bump, not a depth (see MAX_PARALLAX_HEIGHT_SCALE).""" + return self.scale > MAX_PARALLAX_HEIGHT_SCALE + + +def _displacement_remap_max(displacement_node: object) -> float: + """What a Displacement node's Midlevel becomes in the engine. + + The engine's POM is unidirectional (ray-marches INTO the surface from an apparent + flat top; it cannot bulge outward past the true polygon surface the way Blender's + signed displacement-around-Midlevel can). Copying Blender's Midlevel straight into + heightMidlevel does NOT reproduce "neutral gray = no visible depth" — heightMidlevel + is just an additive shift, not a zero-reference (see + HeightMapParallaxOcclusionMapping.md). Instead, it is used as the remap ceiling: raw + values at/above Midlevel clip to "no depth" (the closest unidirectional approximation + of "flush or bulging outward"), and values below it get contrast-stretched into the + full depth range. heightMidlevel itself stays at its neutral default so it remains + available as a separate, manual runtime tuning shift. Clamped to (0, 1] since raw + texture samples are always in that range — an out-of-range authored Midlevel (e.g. an + artist overshooting a slider) would otherwise make the remap divide by a value that + never matches any real sample. A linked Midlevel has no single value: 1, no remap. + """ + midlevel_input = displacement_node.inputs.get("Midlevel") + if midlevel_input is None or midlevel_input.is_linked: + return 1.0 + return min(max(float(midlevel_input.default_value), 0.01), 1.0) + + +def _node_height(node: object, depth_input_name: str, asset_path: Path, remap_max: float = 1.0) -> Optional[MaterialHeight]: + """The height a Displacement node (its depth is its Scale) or a Bump node (its + Distance) gives, None when no image is behind its Height input.""" + height_input = node.inputs.get("Height") + texture = resolve_texture_from_socket(height_input, asset_path) if height_input is not None else None + if texture is None: + return None + depth_input = node.inputs.get(depth_input_name) + scale = DEFAULT_HEIGHT_SCALE + if depth_input is not None and not depth_input.is_linked: + scale = float(depth_input.default_value) + return MaterialHeight(node, texture, scale, remap_max) + + +def _material_height_candidates(node_tree: object, principled: Optional[object], asset_path: Path) -> Iterable[MaterialHeight]: + """The heights a material holds, in the order the export tries them: the one of the + Displacement on the Material Output, then the one of the Bump on the Normal input + of its Principled BSDF, if it has one.""" + output = _material_output_node(node_tree) + displacement_input = output.inputs.get("Displacement") if output is not None else None + if displacement_input is not None and displacement_input.is_linked: + node = displacement_input.links[0].from_node + if node.bl_idname == "ShaderNodeDisplacement": + height = _node_height(node, "Scale", asset_path, _displacement_remap_max(node)) + if height is not None: + yield height + normal_input = principled.inputs.get("Normal") if principled is not None else None + if normal_input is not None and normal_input.is_linked: + node = normal_input.links[0].from_node + if node.bl_idname == "ShaderNodeBump": + # A Bump node has nothing like a Midlevel: no remap. + height = _node_height(node, "Distance", asset_path) + if height is not None: + yield height + + +def material_height(material: object, asset_path: Path) -> tuple[Optional[MaterialHeight], list[tuple[MaterialHeight, str]]]: + """The height a material is exported with, and the heights read on the way to it + that are left out, each with why. + + The Material Output's Displacement input comes first (the standard ArchViz/Poliigon + authoring pattern: an Image Texture feeding a Displacement node's Height socket), + then a Bump node feeding the Principled BSDF's Normal input directly (common in + materials authored without a separate Displacement setup). A node counts only with + an image behind its Height input, and the Bump node is read only when the + Displacement gives no height. See docs/proposals/HeightMapParallaxOcclusionMapping.md + for the domain rationale. + + Two things leave a height out: + + - Its depth: above MAX_PARALLAX_HEIGHT_SCALE it is the shading of a bump. + - Its surface: only a material with a Principled BSDF is exported with a height + (extract_material returns before it asks for one of any other). A Displacement + on a Diffuse, a Glass or an Emission surface is left out whatever its depth, and + says that and not its depth: a smaller Scale would not bring its relief back. + + extract_material exports the first and analyze_material reports the second, so the + material fidelity report cannot say something else than the export does. + """ + node_tree = getattr(material, "node_tree", None) + left_out: list[tuple[MaterialHeight, str]] = [] + if node_tree is None: + return None, left_out + principled = _principled_bsdf_node(node_tree) + for height in _material_height_candidates(node_tree, principled, asset_path): + if principled is None: + left_out.append((height, "a height is exported only with a Principled BSDF surface")) + elif height.too_deep: + left_out.append((height, f"its height of {height.scale:g} is the shading of a bump, too deep to be a parallax depth")) + else: + return height, left_out + return None, left_out + + def extract_material(mesh_object: object, asset_path: Path) -> ExportedMaterial: - material_slots = getattr(mesh_object.data, "materials", []) - material = material_slots[0] if material_slots and material_slots[0] is not None else None + material = mesh_object_material(mesh_object) if material is None: return ExportedMaterial( - name=f"{mesh_object.name}_material", + name=DEFAULT_MATERIAL_NAME, base_color_factor=(1.0, 1.0, 1.0, 1.0), emissive_factor=(0.0, 0.0, 0.0), normal_scale=1.0, @@ -4603,6 +5524,11 @@ def extract_material(mesh_object: object, asset_path: Path) -> ExportedMaterial: # default_value when NO texture is connected (i.e. solid color material). if base_color_input is None or base_color_input.is_linked: base_color = (1.0, 1.0, 1.0, 1.0) + if base_color_input is not None and resolve_texture_from_socket(base_color_input, asset_path) is None: + # Node math with no texture behind it: its colour seen straight on. + value = evaluate_socket_facing(base_color_input) + if value is not None: + base_color = (*(min(max(component, 0.0), 1.0) for component in _as_color(value)), 1.0) else: base_color = vector4(base_color_input.default_value) # Blender 4.0+ splits Emission into "Emission Color" (defaults to white) @@ -4624,6 +5550,10 @@ def extract_material(mesh_object: object, asset_path: Path) -> ExportedMaterial: emissive = (0.0, 0.0, 0.0) elif emissive_input.is_linked: emissive = (emission_strength, emission_strength, emission_strength) + if resolve_texture_from_socket(emissive_input, asset_path) is None: + value = evaluate_socket_facing(emissive_input) + if value is not None: + emissive = tuple(max(component, 0.0) * emission_strength for component in _as_color(value)) else: emissive_default = emissive_input.default_value emissive = ( @@ -4631,9 +5561,11 @@ def extract_material(mesh_object: object, asset_path: Path) -> ExportedMaterial: float(emissive_default[1]) * emission_strength, float(emissive_default[2]) * emission_strength, ) - alpha = float(alpha_input.default_value) if alpha_input is not None else 1.0 + # A linked Alpha's slider is stale, like Base Color's above. + alpha = float(alpha_input.default_value) if alpha_input is not None and not alpha_input.is_linked else 1.0 base_color_texture = resolve_texture_from_socket(base_color_input, asset_path) if base_color_input is not None else None + alpha, alpha_mode, alpha_texture = _material_alpha(material, alpha_input, alpha, base_color_texture, asset_path) normal_texture = resolve_texture_from_socket(normal_input, asset_path) if normal_input is not None else None emissive_texture = resolve_texture_from_socket(emissive_input, asset_path) if emissive_input is not None else None metallic_texture = resolve_texture_from_socket(metallic_input, asset_path) if metallic_input is not None else None @@ -4647,61 +5579,7 @@ def extract_material(mesh_object: object, asset_path: Path) -> ExportedMaterial: strength_input = source.inputs.get("Strength") normal_scale = float(strength_input.default_value) if strength_input is not None else 1.0 - # Height/displacement detection: prefer the Material Output's Displacement input (the - # standard ArchViz/Poliigon authoring pattern — an Image Texture feeding a Displacement - # node's Height socket), falling back to a Bump node feeding the Principled BSDF's Normal - # input directly (common in materials authored without a separate Displacement setup). - # See docs/proposals/HeightMapParallaxOcclusionMapping.md for the domain rationale. - height_texture: Optional[ExportedTexture] = None - height_scale = 0.05 - height_midlevel = 0.5 - height_remap_min = 0.0 - height_remap_max = 1.0 - - material_output = _material_output_node(material.node_tree) if getattr(material, "node_tree", None) is not None else None - displacement_input = material_output.inputs.get("Displacement") if material_output is not None else None - if displacement_input is not None and displacement_input.is_linked: - displacement_source = displacement_input.links[0].from_node - if displacement_source.bl_idname == "ShaderNodeDisplacement": - height_input = displacement_source.inputs.get("Height") - height_texture = resolve_texture_from_socket(height_input, asset_path) if height_input is not None else None - if height_texture is not None: - # Blender's Displacement Scale is a world-space distance, not the engine's - # UV-normalized heightScale — carried through as a starting point only (see - # ExportedMaterial.height_scale docstring), not a precise unit conversion. - scale_input = displacement_source.inputs.get("Scale") - midlevel_input = displacement_source.inputs.get("Midlevel") - if scale_input is not None and not scale_input.is_linked: - height_scale = float(scale_input.default_value) - if midlevel_input is not None and not midlevel_input.is_linked: - # The engine's POM is unidirectional (ray-marches INTO the surface from an - # apparent flat top; it cannot bulge outward past the true polygon surface - # the way Blender's signed displacement-around-Midlevel can). Copying - # Blender's Midlevel straight into heightMidlevel does NOT reproduce - # "neutral gray = no visible depth" — heightMidlevel is just an additive - # shift, not a zero-reference (see HeightMapParallaxOcclusionMapping.md). - # Instead, use it as the remap ceiling: raw values at/above Midlevel clip to - # "no depth" (the closest unidirectional approximation of "flush or bulging - # outward"), and values below it get contrast-stretched into the full depth - # range. heightMidlevel itself stays at its neutral default so it remains - # available as a separate, manual runtime tuning shift. Clamped to (0, 1] - # since raw texture samples are always in that range — an out-of-range - # authored Midlevel (e.g. an artist overshooting a slider) would otherwise - # make the remap divide by a value that never matches any real sample. - blender_midlevel = float(midlevel_input.default_value) - height_remap_max = min(max(blender_midlevel, 0.01), 1.0) - - if height_texture is None and normal_input is not None and normal_input.is_linked: - normal_source = normal_input.links[0].from_node - if normal_source.bl_idname == "ShaderNodeBump": - height_input = normal_source.inputs.get("Height") - height_texture = resolve_texture_from_socket(height_input, asset_path) if height_input is not None else None - if height_texture is not None: - distance_input = normal_source.inputs.get("Distance") - if distance_input is not None and not distance_input.is_linked: - height_scale = float(distance_input.default_value) - # Bump has no Midlevel-equivalent input; height_midlevel/height_remap_max stay - # at their neutral defaults. + height, _ = material_height(material, asset_path) occlusion_texture = _detect_occlusion_texture(material, asset_path) @@ -4720,13 +5598,13 @@ def extract_material(mesh_object: object, asset_path: Path) -> ExportedMaterial: roughness_texture=roughness_texture, emissive_texture=emissive_texture, occlusion_texture=occlusion_texture, - height_texture=height_texture, - height_scale=height_scale, - height_midlevel=height_midlevel, - height_remap_min=height_remap_min, - height_remap_max=height_remap_max, + height_texture=height.texture if height is not None else None, + height_scale=height.scale if height is not None else DEFAULT_HEIGHT_SCALE, + height_remap_max=height.remap_max if height is not None else 1.0, roughness_texture_channel=roughness_texture.channel if roughness_texture is not None else TEXTURE_CHANNEL_R, metallic_texture_channel=metallic_texture.channel if metallic_texture is not None else TEXTURE_CHANNEL_R, + alpha_mode=alpha_mode, + alpha_texture=alpha_texture, ) @@ -4878,7 +5756,7 @@ def _extract_mesh_numpy(mesh_object: object, mesh_data: object, asset_path: Path uv0_flat = np.empty(n_loops * 2, dtype=np.float32) mesh_data.uv_layers[0].data.foreach_get("uv", uv0_flat) loop_uv0 = uv0_flat.reshape(-1, 2) - uv_transform = material_uv_transform(_first_material(mesh_object)) + uv_transform = material_uv_transform(mesh_object_material(mesh_object)) if uv_transform is not None: loop_uv0 = loop_uv0 * np.array(uv_transform.scale, dtype=np.float32) + np.array( uv_transform.offset, dtype=np.float32 @@ -5123,7 +6001,7 @@ def extract_mesh_object( uv1_layer = mesh_data.uv_layers[1].data if len(mesh_data.uv_layers) > 1 else None if has_uvs: mesh_data.calc_tangents(uvmap=mesh_data.uv_layers[0].name) - uv_transform = material_uv_transform(_first_material(mesh_object)) if has_uvs else None + uv_transform = material_uv_transform(mesh_object_material(mesh_object)) if has_uvs else None color_layer = mesh_data.color_attributes.active_color vertex_writer = BinaryWriter() @@ -5288,6 +6166,25 @@ def extract_meshes( ] +def mesh_share_key(obj: object) -> Optional[tuple]: + """Identifies mesh objects whose exported mesh is the same: linked duplicates (one + mesh datablock, Alt+D in Blender) with the same materials and nothing that changes + the mesh per object. Their geometry is then split and extracted once and reused. + + None for anything that can differ per object: modifiers (a Mirror or Array can + depend on the object), skinning or shape keys (see _is_rigged_object). + """ + if getattr(obj, "type", None) != "MESH" or getattr(obj, "data", None) is None: + return None + if getattr(obj, "modifiers", None) or _is_rigged_object(obj): + return None + materials = tuple( + slot.material.as_pointer() if getattr(slot, "material", None) is not None else 0 + for slot in getattr(obj, "material_slots", []) + ) + return (obj.data.as_pointer(), materials) + + def _is_rigged_object(obj: object) -> bool: """Whether a mesh object carries skinning or morph targets: an Armature modifier, or shape keys.""" @@ -5366,6 +6263,10 @@ def split_blender_objects_by_material(objects: list[object]) -> list[object]: # scene, which invalidates the depsgraph. evaluated_meshes: dict[int, object] = {} depsgraph = None + # Copies of one mesh (see mesh_share_key) are cut once: the first one's fragment + # meshes are linked into the others' fragment objects. + fragments_by_share_key: dict[tuple, list[tuple[int, object]]] = {} + keys_to_cut: set[tuple] = set() for obj in objects: if getattr(obj, "type", None) != "MESH" or obj.data is None: continue @@ -5373,6 +6274,11 @@ def split_blender_objects_by_material(objects: list[object]) -> list[object]: continue if _is_rigged_object(obj): continue + share_key = mesh_share_key(obj) + if share_key is not None: + if share_key in keys_to_cut: + continue + keys_to_cut.add(share_key) if depsgraph is None: depsgraph = bpy.context.evaluated_depsgraph_get() evaluated_meshes[obj.as_pointer()] = bpy.data.meshes.new_from_object( @@ -5385,6 +6291,12 @@ def split_blender_objects_by_material(objects: list[object]) -> list[object]: if getattr(obj, "type", None) != "MESH" or obj.data is None: result.append(obj) continue + share_key = mesh_share_key(obj) + shared_fragments = fragments_by_share_key.get(share_key) if share_key is not None else None + if shared_fragments is not None: + for mat_idx, fragment_mesh in shared_fragments: + result.append(_material_fragment_object(obj, mat_idx, fragment_mesh)) + continue evaluated_mesh = evaluated_meshes.pop(obj.as_pointer(), None) mesh = evaluated_mesh if evaluated_mesh is not None else obj.data used_indices = {p.material_index for p in mesh.polygons} @@ -5398,6 +6310,7 @@ def split_blender_objects_by_material(objects: list[object]) -> list[object]: if _is_rigged_object(obj): result.extend(_separate_rigged_object_by_material(obj)) continue + fragments: list[tuple[int, object]] = [] for mat_idx in sorted(used_indices): bm = _bmesh.new() try: @@ -5421,30 +6334,38 @@ def split_blender_objects_by_material(objects: list[object]) -> list[object]: new_mesh.materials.append(mat) for p in new_mesh.polygons: p.material_index = 0 - new_obj = bpy.data.objects.new(f"{obj.name}_mat{mat_idx}", new_mesh) - # Preserve the source object's parent link (if any) so nodes that - # already sit under a real Blender hierarchy still group correctly; - # UNTOLD_MATERIAL_SPLIT_SOURCE_PROP below is what reunites fragments - # of a *parentless* multi-material object, which parent-chain - # walking alone can't do since these fragments aren't parented to - # each other. - new_obj.parent = obj.parent - if obj.parent is not None: - new_obj.matrix_parent_inverse = obj.matrix_parent_inverse.copy() - new_obj.matrix_world = obj.matrix_world.copy() - new_obj[UNTOLD_EXPORT_TEMP_OBJECT_PROP] = True - # A stand-in being split (a converted curve) passes on the object it - # already stands in for. - new_obj[UNTOLD_MATERIAL_SPLIT_SOURCE_PROP] = obj.get(UNTOLD_MATERIAL_SPLIT_SOURCE_PROP) or obj.name - bpy.context.scene.collection.objects.link(new_obj) - result.append(new_obj) + fragments.append((mat_idx, new_mesh)) + result.append(_material_fragment_object(obj, mat_idx, new_mesh)) finally: bm.free() + if share_key is not None: + fragments_by_share_key[share_key] = fragments if evaluated_mesh is not None: bpy.data.meshes.remove(evaluated_mesh) return result +def _material_fragment_object(obj: object, mat_idx: int, mesh: object) -> object: + """A temporary object for one material's fragment of obj, placed like obj.""" + import bpy + new_obj = bpy.data.objects.new(f"{obj.name}_mat{mat_idx}", mesh) + # Preserve the source object's parent link (if any) so nodes that already sit + # under a real Blender hierarchy still group correctly; + # UNTOLD_MATERIAL_SPLIT_SOURCE_PROP below is what reunites fragments of a + # *parentless* multi-material object, which parent-chain walking alone can't do + # since these fragments aren't parented to each other. + new_obj.parent = obj.parent + if obj.parent is not None: + new_obj.matrix_parent_inverse = obj.matrix_parent_inverse.copy() + new_obj.matrix_world = obj.matrix_world.copy() + new_obj[UNTOLD_EXPORT_TEMP_OBJECT_PROP] = True + # A stand-in being split (a converted curve) passes on the object it already + # stands in for. + new_obj[UNTOLD_MATERIAL_SPLIT_SOURCE_PROP] = obj.get(UNTOLD_MATERIAL_SPLIT_SOURCE_PROP) or obj.name + bpy.context.scene.collection.objects.link(new_obj) + return new_obj + + def cleanup_temporary_export_objects(objects: Iterable[object]) -> None: """Remove temporary Blender objects created for one export pass, including those no longer in the export list (a converted curve that was then split).""" @@ -5516,17 +6437,42 @@ def extract_scene_payload_from_current_scene( mesh_name: Optional[str], convert_orientation: bool = False, source_orientation: str = "blender-native", + include_hidden: bool = False, ) -> tuple[list[ExportedLight], list[ExportedCamera]]: + """The scene's lights and cameras, by the same rule as its meshes (see + filter_scene_objects_for_export): never from collections excluded from the view + layer, and hidden ones only with include_hidden.""" blender_required() include_scene_payload = mesh_name is None return extract_scene_payload_from_objects( - list(bpy.data.objects), + filter_scene_objects_for_export(list(bpy.context.scene.objects), include_hidden=include_hidden, quiet=True), convert_orientation=convert_orientation, source_orientation=source_orientation, include_scene_payload=include_scene_payload, ) +def placed_mesh_copy(mesh: ExportedMesh, obj: object, conversion_matrix: Optional[object]) -> ExportedMesh: + """An extracted mesh for another object that copies its mesh (see mesh_share_key): + the same vertices, indices and material, with the object's own name, parent and + transform. Its world bounds are the local bounds' corners through the object's + world transform (a box around the exact bounds, which only empty nodes' bounds use + before normalize_export_nodes recomputes them from the vertices).""" + local_rows = matrix_rows_from_blender(obj.matrix_local) + world_rows = matrix_rows_from_blender(obj.matrix_world) + if conversion_matrix is not None: + local_rows = transform_matrix_rows(local_rows, conversion_matrix) + world_rows = transform_matrix_rows(world_rows, conversion_matrix) + corners = [transform_point_rows(world_rows, corner) for corner in aabb_corners(mesh.local_bounds)] + return replace( + mesh, + entity_name=obj.get("mesh_original_name") or obj.name, + parent_entity_name=getattr(getattr(obj, "parent", None), "name", None), + local_transform_rows=local_rows, + world_bounds=aabb_from_points(corners), + ) + + def extract_nodes_from_objects( export_objects: list[object], asset_path: Path, @@ -5548,27 +6494,45 @@ def extract_nodes_from_objects( total = len(mesh_objects) print(f" Processing {total} mesh(es) ...", flush=True) exported_meshes_by_name: dict[str, ExportedMesh] = {} + # Copies of one mesh (see mesh_share_key) are extracted once; each copy takes the + # result with its own placement. An extraction that failed fails for every copy. + extracted_by_share_key: dict[tuple, object] = {} + reused = 0 skipped = 0 for i, obj in enumerate(mesh_objects, 1): percent = (100.0 * i) / max(total, 1) print(f" [{i}/{total} | {percent:6.2f}%] {obj.name}", flush=True) + share_key = mesh_share_key(obj) try: - exported_meshes_by_name[obj.name] = extract_mesh_object( - obj, - asset_path, - convert_orientation=convert_orientation, - source_orientation=source_orientation, - _cached_conversion_matrix=conversion_matrix, - _depsgraph=depsgraph, - _validate=validate, - ) + earlier = extracted_by_share_key.get(share_key) if share_key is not None else None + if isinstance(earlier, ExportedMesh): + exported_meshes_by_name[obj.name] = placed_mesh_copy(earlier, obj, conversion_matrix) + reused += 1 + elif isinstance(earlier, RuntimeError): + raise earlier + else: + exported_meshes_by_name[obj.name] = extract_mesh_object( + obj, + asset_path, + convert_orientation=convert_orientation, + source_orientation=source_orientation, + _cached_conversion_matrix=conversion_matrix, + _depsgraph=depsgraph, + _validate=validate, + ) + if share_key is not None: + extracted_by_share_key[share_key] = exported_meshes_by_name[obj.name] except RuntimeError as exc: + if share_key is not None: + extracted_by_share_key.setdefault(share_key, exc) print(f" Skipped: {exc}", flush=True) skipped += 1 if progress_callback is not None: progress_callback("Extract meshes", i, total, obj.name) if skipped: print(f" Skipped {skipped} mesh(es) with errors", flush=True) + if reused: + print(f" Reused the geometry of {reused} mesh(es) that copy another one", flush=True) for report_line in material_fidelity_report_lines(mesh_objects): print(f" {report_line}", flush=True) @@ -5836,6 +6800,7 @@ def add_material(material: ExportedMaterial) -> int: material.height_remap_max, material.roughness_texture_channel, material.metallic_texture_channel, + material.alpha_mode, ) existing = material_indices.get(key) if existing is not None: @@ -5846,7 +6811,7 @@ def add_material(material: ExportedMaterial) -> int: materials.append( MaterialRecord( name_offset=string_table.add(material.name), - flags=0, + flags=material.alpha_mode, base_color_factor=material.base_color_factor, emissive_factor=material.emissive_factor, normal_scale=material.normal_scale, @@ -6809,6 +7774,7 @@ def write_single_untold_from_nodes( Blender add-on's "Export Untold Asset" operator behave identically when a scene's model topology changes between runs at the same --output stem. """ + output_path = single_file_output_path(output_path) exported_nodes = normalize_export_nodes(exported_nodes) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -6886,6 +7852,46 @@ def write_single_untold_from_nodes( } +def model_content_signature(nodes: list[ExportedNode], digests: dict[int, tuple[bytes, str]]) -> Optional[str]: + """A fingerprint of what a pack model's .untold would hold, independent of where the + model stands (its root transform goes in the manifest) and of its objects' names: + geometry, materials (a mesh with none has the same one as any other, see + DEFAULT_MATERIAL_NAME) and hierarchy. + Two models with the same fingerprint are the same model placed twice, and are + written once. None for a model that is not compared: skinned or with morph targets. + + digests caches the hash of each geometry buffer by object identity, since copies + of a mesh share their buffers (see placed_mesh_copy). + """ + def digest(data: bytes) -> str: + # Keyed by identity, holding the buffer so its identity cannot be reused. + cached = digests.get(id(data)) + if cached is None or cached[0] is not data: + cached = (data, hashlib.sha1(data).hexdigest()) + digests[id(data)] = cached + return cached[1] + + position = {node.entity_name: index for index, node in enumerate(nodes)} + parts = [] + for node in nodes: + if node.skeleton is not None: + return None + mesh = node.mesh + if mesh is not None and (mesh.skin_binding is not None or mesh.morph_targets): + return None + is_root = node.parent_entity_name is None or node.parent_entity_name not in position + rows = identity_matrix_rows() if is_root else node.local_transform_rows + mesh_part = None + if mesh is not None: + mesh_part = (digest(mesh.vertices), digest(mesh.indices), mesh.index_type, repr(mesh.material)) + parts.append(( + None if is_root else position[node.parent_entity_name], + tuple(tuple(round(float(value), 6) for value in row) for row in rows), + mesh_part, + )) + return hashlib.sha1(repr(parts).encode("utf-8")).hexdigest() + + def write_untold_pack_from_groups( model_groups: dict[str, list[ExportedNode]], *, @@ -6905,7 +7911,13 @@ def write_untold_pack_from_groups( The per-model folders go in assets_dir when one is given (see --assets-dir), else beside the manifest; the manifest's model paths are relative to its own folder either way. + + A model that is a copy of one already written (see model_content_signature) is not + written again: its manifest entry points at the first one's .untold with its own + transform. Textures go in one Textures/ folder beside the model folders, shared by + every model, instead of a copy in each. """ + output_path = single_file_output_path(output_path) pack_path = output_path.with_suffix(".untoldpack") models_root = assets_dir or output_path.parent # Captured before write_untoldpack_manifest() overwrites pack_path below, so @@ -6923,7 +7935,13 @@ def write_untold_pack_from_groups( total_bytes = 0 skipped_textures: list[str] = [] write_failures = TextureWriteFailures() + # One staging for the whole pack: Textures/ beside the model folders, each texture + # written once and referenced from every model that uses it (../Textures/...). + texture_context = TextureStagingContext(skipped_textures, write_failures, assets_dir=models_root) used_model_dir_names: set[str] = set() + written_by_signature: dict[str, Path] = {} + geometry_digests: dict[int, tuple[bytes, str]] = {} + shared_model_count = 0 for root_name, raw_group_nodes in model_groups.items(): # The root's own placement is captured here, from the un-baked node, and # carried in the manifest instead of being baked into the geometry @@ -6932,18 +7950,36 @@ def write_untold_pack_from_groups( original_root_transform = next( node.local_transform_rows for node in raw_group_nodes if node.parent_entity_name is None ) + signature = model_content_signature(raw_group_nodes, geometry_digests) + written = written_by_signature.get(signature) if signature is not None else None + if written is not None: + manifest_models.append( + { + "displayName": root_name, + "path": relative_asset_uri(written, pack_path.parent), + "transform": original_root_transform, + } + ) + shared_model_count += 1 + if progress_callback is not None: + progress_callback("Share models", 0, 1, f"{root_name} -> {written.name}") + continue + group_nodes = normalize_export_nodes(zero_root_transform(raw_group_nodes)) model_dir_name = unique_pack_model_dir_name(root_name, used_model_dir_names) model_output_path = models_root / model_dir_name / f"{model_dir_name}.untold" model_output_path.parent.mkdir(parents=True, exist_ok=True) + # A Textures/ folder of the model's own from an earlier export is no longer used. + own_textures = model_output_path.parent / "Textures" + if own_textures.is_dir(): + shutil.rmtree(own_textures) staged_group_nodes = stage_nodes_for_output( group_nodes, model_output_path, progress_callback=progress_callback, - skipped_textures=skipped_textures, - write_failures=write_failures, + context=texture_context, ) model_bytes = build_untold_file( staged_group_nodes, @@ -6975,6 +8011,8 @@ def write_untold_pack_from_groups( } ) new_model_dirs.append(model_output_path.parent.resolve()) + if signature is not None: + written_by_signature[signature] = model_output_path write_untoldpack_manifest(pack_path, source_asset_name, manifest_models) @@ -7002,6 +8040,8 @@ def write_untold_pack_from_groups( "models": manifest_models, "model_paths": model_paths, "model_count": len(manifest_models), + "written_model_count": len(model_paths), + "shared_model_count": shared_model_count, "mesh_count": total_meshes, "vertex_count": total_vertices, "index_count": total_indices, @@ -7110,7 +8150,14 @@ def parse_args(argv: list[str]) -> argparse.Namespace: argv = argv[1:] parser = argparse.ArgumentParser(description="Cook USD scene or animation data into UntoldEngine's .untold format.") parser.add_argument("--input", required=True, help="Path to a source USD/USDZ asset or a .blend file.") - parser.add_argument("--output", required=True, help="Path to the output .untold file (or .untoldanim with --animation).") + parser.add_argument( + "--output", + required=True, + help=( + "Path to the output .untold file (or .untoldanim with --animation). A scene with several " + "models is written as a .untoldpack of the same name; that name may be given as well." + ), + ) parser.add_argument("--file-type", default="tile", choices=sorted(FILE_TYPES.keys()), help="Untold file type to emit.") parser.add_argument("--mesh-name", default=None, help="Optional mesh object name when the USD asset imports multiple meshes.") parser.add_argument( @@ -7174,7 +8221,7 @@ def main(argv: list[str]) -> int: args = parse_args(argv) EXPORT_SHAPE_KEYS = bool(getattr(args, "export_shapekeys", False)) input_path = normalize_blender_path(args.input) - output_path = normalize_blender_path(args.output) + output_path = single_file_output_path(normalize_blender_path(args.output)) assets_dir = normalize_blender_path(args.assets_dir) if args.assets_dir else None if input_path.suffix.lower() not in {".usd", ".usda", ".usdc", ".usdz", ".blend"}: @@ -7222,6 +8269,7 @@ def main(argv: list[str]) -> int: mesh_name=args.mesh_name, convert_orientation=args.convert_orientation, source_orientation=args.source_orientation, + include_hidden=args.include_hidden, ) clean_generated_sidecar_dirs(output_path, assets_dir) @@ -7301,6 +8349,12 @@ def main(argv: list[str]) -> int: ) progress.advance("Build file", result["pack_path"].name) print(f"Wrote {result['pack_path']} ({result['model_count']} model(s))") + if result["shared_model_count"]: + print( + f"{result['written_model_count']} model file(s) written; {result['shared_model_count']} " + "placement(s) reuse a model already written", + flush=True, + ) print(f"Nodes: {len(exported_nodes)}, Meshes: {result['mesh_count']}") if staged_hdr_assets: print(f"HDR environments: {len(staged_hdr_assets)}")