// // ModelBaker.cpp // libraries/baking/src // // Created by Utkarsh Gautam on 9/29/17. // Copyright 2017 High Fidelity, Inc. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // #include "ModelBaker.h" #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #pragma warning( push ) #pragma warning( disable : 4267 ) #endif #include #include #ifdef HIFI_DUMP_FBX #include "FBXToJSON.h" #endif #ifdef _WIN32 #pragma warning( pop ) #endif #include "baking/BakerLibrary.h" ModelBaker::ModelBaker(const QUrl& inputModelURL, TextureBakerThreadGetter inputTextureThreadGetter, const QString& bakedOutputDirectory, const QString& originalOutputDirectory, bool hasBeenBaked) : _modelURL(inputModelURL), _bakedOutputDir(bakedOutputDirectory), _originalOutputDir(originalOutputDirectory), _textureThreadGetter(inputTextureThreadGetter), _hasBeenBaked(hasBeenBaked) { auto tempDir = PathUtils::generateTemporaryDir(); if (tempDir.isEmpty()) { handleError("Failed to create a temporary directory."); return; } _modelTempDir = tempDir; _originalModelFilePath = _modelTempDir.filePath(_modelURL.fileName()); qDebug() << "Made temporary dir " << _modelTempDir; qDebug() << "Origin file path: " << _originalModelFilePath; { auto bakedFilename = _modelURL.fileName(); if (!hasBeenBaked) { bakedFilename = bakedFilename.left(bakedFilename.lastIndexOf('.')); bakedFilename += BAKED_FBX_EXTENSION; } _bakedModelURL = _bakedOutputDir + "/" + bakedFilename; } } ModelBaker::~ModelBaker() { if (_modelTempDir.exists()) { if (!_modelTempDir.remove(_originalModelFilePath)) { qCWarning(model_baking) << "Failed to remove temporary copy of model file:" << _originalModelFilePath; } if (!_modelTempDir.rmdir(".")) { qCWarning(model_baking) << "Failed to remove temporary directory:" << _modelTempDir; } } } void ModelBaker::setOutputURLSuffix(const QUrl& outputURLSuffix) { _outputURLSuffix = outputURLSuffix; } void ModelBaker::setMappingURL(const QUrl& mappingURL) { _mappingURL = mappingURL; } void ModelBaker::setMapping(const hifi::VariantHash& mapping) { _mapping = mapping; } QUrl ModelBaker::getFullOutputMappingURL() const { QUrl appendedURL = _outputMappingURL; appendedURL.setFragment(_outputURLSuffix.fragment()); appendedURL.setQuery(_outputURLSuffix.query()); appendedURL.setUserInfo(_outputURLSuffix.userInfo()); return appendedURL; } void ModelBaker::bake() { qDebug() << "ModelBaker" << _modelURL << "bake starting"; // Setup the output folders for the results of this bake initializeOutputDirs(); if (shouldStop()) { return; } connect(this, &ModelBaker::modelLoaded, this, &ModelBaker::bakeSourceCopy); // make a local copy of the model saveSourceModel(); } void ModelBaker::initializeOutputDirs() { // Attempt to make the output folders // Warn if there is an output directory using the same name, unless we know a parent FST baker created them already if (QDir(_bakedOutputDir).exists()) { if (_mappingURL.isEmpty()) { qWarning() << "Output path" << _bakedOutputDir << "already exists. Continuing."; } } else { qCDebug(model_baking) << "Creating baked output folder" << _bakedOutputDir; if (!QDir().mkpath(_bakedOutputDir)) { handleError("Failed to create baked output folder " + _bakedOutputDir); return; } } if (QDir(_originalOutputDir).exists()) { if (_mappingURL.isEmpty()) { qWarning() << "Output path" << _originalOutputDir << "already exists. Continuing."; } } else { qCDebug(model_baking) << "Creating original output folder" << _originalOutputDir; if (!QDir().mkpath(_originalOutputDir)) { handleError("Failed to create original output folder " + _originalOutputDir); } } } void ModelBaker::saveSourceModel() { // check if the FBX is local or first needs to be downloaded if (_modelURL.isLocalFile()) { // load up the local file QFile localModelURL { _modelURL.toLocalFile() }; qDebug() << "Local file url: " << _modelURL << _modelURL.toString() << _modelURL.toLocalFile() << ", copying to: " << _originalModelFilePath; if (!localModelURL.exists()) { //QMessageBox::warning(this, "Could not find " + _modelURL.toString(), ""); handleError("Could not find " + _modelURL.toString()); return; } // make a copy in the output folder if (!_originalOutputDir.isEmpty()) { qDebug() << "Copying to: " << _originalOutputDir << "/" << _modelURL.fileName(); localModelURL.copy(_originalOutputDir + "/" + _modelURL.fileName()); } localModelURL.copy(_originalModelFilePath); // emit our signal to start the import of the model source copy emit modelLoaded(); } else { // remote file, kick off a download auto& networkAccessManager = NetworkAccessManager::getInstance(); QNetworkRequest networkRequest; // setup the request to follow re-directs and always hit the network networkRequest.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); networkRequest.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); networkRequest.setHeader(QNetworkRequest::UserAgentHeader, HIGH_FIDELITY_USER_AGENT); networkRequest.setUrl(_modelURL); qCDebug(model_baking) << "Downloading" << _modelURL; auto networkReply = networkAccessManager.get(networkRequest); connect(networkReply, &QNetworkReply::finished, this, &ModelBaker::handleModelNetworkReply); } } void ModelBaker::handleModelNetworkReply() { auto requestReply = qobject_cast(sender()); if (requestReply->error() == QNetworkReply::NoError) { qCDebug(model_baking) << "Downloaded" << _modelURL; // grab the contents of the reply and make a copy in the output folder QFile copyOfOriginal(_originalModelFilePath); qDebug(model_baking) << "Writing copy of original model file to" << _originalModelFilePath << copyOfOriginal.fileName(); if (!copyOfOriginal.open(QIODevice::WriteOnly)) { // add an error to the error list for this model stating that a duplicate of the original model could not be made handleError("Could not create copy of " + _modelURL.toString() + " (Failed to open " + _originalModelFilePath + ")"); return; } if (copyOfOriginal.write(requestReply->readAll()) == -1) { handleError("Could not create copy of " + _modelURL.toString() + " (Failed to write)"); return; } // close that file now that we are done writing to it copyOfOriginal.close(); if (!_originalOutputDir.isEmpty()) { copyOfOriginal.copy(_originalOutputDir + "/" + _modelURL.fileName()); } // emit our signal to start the import of the model source copy emit modelLoaded(); } else { // add an error to our list stating that the model could not be downloaded handleError("Failed to download " + _modelURL.toString()); } } void ModelBaker::bakeSourceCopy() { QFile modelFile(_originalModelFilePath); if (!modelFile.open(QIODevice::ReadOnly)) { handleError("Error opening " + _originalModelFilePath + " for reading"); return; } hifi::ByteArray modelData = modelFile.readAll(); hfm::Model::Pointer bakedModel; std::vector dracoMeshes; std::vector> dracoMaterialLists; // Material order for per-mesh material lookup used by dracoMeshes { auto serializer = DependencyManager::get()->getSerializerForMediaType(modelData, _modelURL, ""); if (!serializer) { handleError("Could not recognize file type of model file " + _originalModelFilePath); return; } hifi::VariantHash serializerMapping = _mapping; serializerMapping["combineParts"] = true; // set true so that OBJSerializer reads material info from material library serializerMapping["deduplicateIndices"] = true; // Draco compression also deduplicates, but we might as well shave it off to save on some earlier processing (currently FBXSerializer only) hfm::Model::Pointer loadedModel = serializer->read(modelData, serializerMapping, _modelURL); // Temporarily support copying the pre-parsed node from FBXSerializer, for better performance in FBXBaker // TODO: Pure HFM baking std::shared_ptr fbxSerializer = std::dynamic_pointer_cast(serializer); if (fbxSerializer) { qCDebug(model_baking) << "Parsing" << _modelURL; _rootNode = fbxSerializer->_rootNode; } baker::Baker baker(loadedModel, serializerMapping, _mappingURL); auto config = baker.getConfiguration(); // Enable compressed draco mesh generation config->getJobConfig("BuildDracoMesh")->setEnabled(true); // Do not permit potentially lossy modification of joint data meant for runtime ((PrepareJointsConfig*)config->getJobConfig("PrepareJoints"))->passthrough = true; // The resources parsed from this job will not be used for now // TODO: Proper full baking of all materials for a model config->getJobConfig("ParseMaterialMapping")->setEnabled(false); // Begin hfm baking baker.run(); bakedModel = baker.getHFMModel(); dracoMeshes = baker.getDracoMeshes(); dracoMaterialLists = baker.getDracoMaterialLists(); } // Populate _textureContentMap with path to content mappings, for quick lookup by URL for (auto materialIt = bakedModel->materials.cbegin(); materialIt != bakedModel->materials.cend(); materialIt++) { static const auto addTexture = [](QHash& textureContentMap, const hfm::Texture& texture) { if (!textureContentMap.contains(texture.filename)) { // Content may be empty, unless the data is inlined textureContentMap[texture.filename] = texture.content; } }; const hfm::Material& material = *materialIt; addTexture(_textureContentMap, material.normalTexture); addTexture(_textureContentMap, material.albedoTexture); addTexture(_textureContentMap, material.opacityTexture); addTexture(_textureContentMap, material.glossTexture); addTexture(_textureContentMap, material.roughnessTexture); addTexture(_textureContentMap, material.specularTexture); addTexture(_textureContentMap, material.metallicTexture); addTexture(_textureContentMap, material.emissiveTexture); addTexture(_textureContentMap, material.occlusionTexture); addTexture(_textureContentMap, material.scatteringTexture); addTexture(_textureContentMap, material.lightmapTexture); } // Do format-specific baking bakeProcessedSource(bakedModel, dracoMeshes, dracoMaterialLists); if (shouldStop()) { return; } // Output FST file, copying over input mappings if available QString outputFSTFilename = !_mappingURL.isEmpty() ? _mappingURL.fileName() : _modelURL.fileName(); auto extensionStart = outputFSTFilename.indexOf("."); if (extensionStart != -1) { outputFSTFilename.resize(extensionStart); } outputFSTFilename += ".baked.fst"; QString outputFSTURL = _bakedOutputDir + "/" + outputFSTFilename; auto outputMapping = _mapping; outputMapping[FST_VERSION_FIELD] = FST_VERSION; outputMapping[FILENAME_FIELD] = _bakedModelURL.fileName(); // All textures will be found in the same directory as the model outputMapping[TEXDIR_FIELD] = "."; hifi::ByteArray fstOut = FSTReader::writeMapping(outputMapping); QFile fstOutputFile { outputFSTURL }; if (!fstOutputFile.open(QIODevice::WriteOnly)) { handleError("Failed to open file '" + outputFSTURL + "' for writing"); return; } if (fstOutputFile.write(fstOut) == -1) { handleError("Failed to write to file '" + outputFSTURL + "'"); return; } _outputFiles.push_back(outputFSTURL); _outputMappingURL = outputFSTURL; // check if we're already done with textures (in case we had none to re-write) checkIfTexturesFinished(); } void ModelBaker::abort() { Baker::abort(); // tell our underlying TextureBaker instances to abort // the ModelBaker will wait until all are aborted before emitting its own abort signal for (auto& textureBaker : _bakingTextures) { textureBaker->abort(); } } bool ModelBaker::buildDracoMeshNode(FBXNode& dracoMeshNode, const QByteArray& dracoMeshBytes, const std::vector& dracoMaterialList) { if (dracoMeshBytes.isEmpty()) { handleError("Failed to finalize the baking of a draco Geometry node"); return false; } FBXNode dracoNode; dracoNode.name = "DracoMesh"; dracoNode.properties.append(QVariant::fromValue(dracoMeshBytes)); // Additional draco mesh node information { FBXNode fbxVersionNode; fbxVersionNode.name = "FBXDracoMeshVersion"; fbxVersionNode.properties.append(FBX_DRACO_MESH_VERSION); dracoNode.children.append(fbxVersionNode); FBXNode dracoVersionNode; dracoVersionNode.name = "DracoMeshVersion"; dracoVersionNode.properties.append(DRACO_MESH_VERSION); dracoNode.children.append(dracoVersionNode); FBXNode materialListNode; materialListNode.name = "MaterialList"; for (const hifi::ByteArray& materialID : dracoMaterialList) { materialListNode.properties.append(materialID); } dracoNode.children.append(materialListNode); } dracoMeshNode = dracoNode; return true; } QString ModelBaker::compressTexture(QString modelTextureFileName, image::TextureUsage::Type textureType) { QFileInfo modelTextureFileInfo { modelTextureFileName.replace("\\", "/") }; if (modelTextureFileInfo.suffix().toLower() == BAKED_TEXTURE_KTX_EXT.mid(1)) { // re-baking a model that already references baked textures // this is an error - return from here handleError("Cannot re-bake a file that already references compressed textures"); return QString::null; } if (!image::getSupportedFormats().contains(modelTextureFileInfo.suffix())) { // this is a texture format we don't bake, skip it handleWarning(modelTextureFileName + " is not a bakeable texture format"); return QString::null; } // make sure this texture points to something and isn't one we've already re-mapped QString textureChild { QString::null }; if (!modelTextureFileInfo.filePath().isEmpty()) { // check if this was an embedded texture that we already have in-memory content for QByteArray textureContent; // figure out the URL to this texture, embedded or external if (!modelTextureFileInfo.filePath().isEmpty()) { textureContent = _textureContentMap.value(modelTextureFileName.toLocal8Bit()); } auto urlToTexture = getTextureURL(modelTextureFileInfo, modelTextureFileName, !textureContent.isNull()); QString baseTextureFileName; if (_remappedTexturePaths.contains(urlToTexture)) { baseTextureFileName = _remappedTexturePaths[urlToTexture]; } else { // construct the new baked texture file name and file path // ensuring that the baked texture will have a unique name // even if there was another texture with the same name at a different path baseTextureFileName = createBaseTextureFileName(modelTextureFileInfo, textureType); _remappedTexturePaths[urlToTexture] = baseTextureFileName; } qCDebug(model_baking).noquote() << "Re-mapping" << modelTextureFileName << "to" << baseTextureFileName; QString bakedTextureFilePath { _bakedOutputDir + "/" + baseTextureFileName + BAKED_META_TEXTURE_SUFFIX }; textureChild = baseTextureFileName + BAKED_META_TEXTURE_SUFFIX; if (!_bakingTextures.contains(urlToTexture)) { _outputFiles.push_back(bakedTextureFilePath); // bake this texture asynchronously bakeTexture(urlToTexture, textureType, _bakedOutputDir, baseTextureFileName, textureContent); } } return textureChild; } void ModelBaker::bakeTexture(const QUrl& textureURL, image::TextureUsage::Type textureType, const QDir& outputDir, const QString& bakedFilename, const QByteArray& textureContent) { // start a bake for this texture and add it to our list to keep track of QSharedPointer bakingTexture{ new TextureBaker(textureURL, textureType, outputDir, "../", bakedFilename, textureContent), &TextureBaker::deleteLater }; // make sure we hear when the baking texture is done or aborted connect(bakingTexture.data(), &Baker::finished, this, &ModelBaker::handleBakedTexture); connect(bakingTexture.data(), &TextureBaker::aborted, this, &ModelBaker::handleAbortedTexture); // keep a shared pointer to the baking texture _bakingTextures.insert(textureURL, bakingTexture); // start baking the texture on one of our available worker threads bakingTexture->moveToThread(_textureThreadGetter()); QMetaObject::invokeMethod(bakingTexture.data(), "bake"); } void ModelBaker::handleBakedTexture() { TextureBaker* bakedTexture = qobject_cast(sender()); qDebug() << "Handling baked texture" << bakedTexture->getTextureURL(); // make sure we haven't already run into errors, and that this is a valid texture if (bakedTexture) { if (!shouldStop()) { if (!bakedTexture->hasErrors()) { if (!_originalOutputDir.isEmpty()) { // we've been asked to make copies of the originals, so we need to make copies of this if it is a linked texture // use the path to the texture being baked to determine if this was an embedded or a linked texture // it is embeddded if the texure being baked was inside a folder with the name of the model // since that is the fake URL we provide when baking external textures if (!_modelURL.isParentOf(bakedTexture->getTextureURL())) { // for linked textures we want to save a copy of original texture beside the original model qCDebug(model_baking) << "Saving original texture for" << bakedTexture->getTextureURL(); // check if we have a relative path to use for the texture auto relativeTexturePath = texturePathRelativeToModel(_modelURL, bakedTexture->getTextureURL()); QFile originalTextureFile{ _originalOutputDir + "/" + relativeTexturePath + bakedTexture->getTextureURL().fileName() }; if (relativeTexturePath.length() > 0) { // make the folders needed by the relative path } if (originalTextureFile.open(QIODevice::WriteOnly) && originalTextureFile.write(bakedTexture->getOriginalTexture()) != -1) { qCDebug(model_baking) << "Saved original texture file" << originalTextureFile.fileName() << "for" << _modelURL; } else { handleError("Could not save original external texture " + originalTextureFile.fileName() + " for " + _modelURL.toString()); return; } } } // now that this texture has been baked and handled, we can remove that TextureBaker from our hash _bakingTextures.remove(bakedTexture->getTextureURL()); checkIfTexturesFinished(); } else { // there was an error baking this texture - add it to our list of errors _errorList.append(bakedTexture->getErrors()); // we don't emit finished yet so that the other textures can finish baking first _pendingErrorEmission = true; // now that this texture has been baked, even though it failed, we can remove that TextureBaker from our list _bakingTextures.remove(bakedTexture->getTextureURL()); // abort any other ongoing texture bakes since we know we'll end up failing for (auto& bakingTexture : _bakingTextures) { bakingTexture->abort(); } checkIfTexturesFinished(); } } else { // we have errors to attend to, so we don't do extra processing for this texture // but we do need to remove that TextureBaker from our list // and then check if we're done with all textures _bakingTextures.remove(bakedTexture->getTextureURL()); checkIfTexturesFinished(); } } } void ModelBaker::handleAbortedTexture() { // grab the texture bake that was aborted and remove it from our hash since we don't need to track it anymore TextureBaker* bakedTexture = qobject_cast(sender()); qDebug() << "Texture aborted: " << bakedTexture->getTextureURL(); if (bakedTexture) { _bakingTextures.remove(bakedTexture->getTextureURL()); } // since a texture we were baking aborted, our status is also aborted _shouldAbort.store(true); // abort any other ongoing texture bakes since we know we'll end up failing for (auto& bakingTexture : _bakingTextures) { bakingTexture->abort(); } checkIfTexturesFinished(); } QUrl ModelBaker::getTextureURL(const QFileInfo& textureFileInfo, QString relativeFileName, bool isEmbedded) { QUrl urlToTexture; // use QFileInfo to easily split up the existing texture filename into its components auto apparentRelativePath = QFileInfo(relativeFileName.replace("\\", "/")); if (isEmbedded) { urlToTexture = _modelURL.toString() + "/" + apparentRelativePath.filePath(); } else { if (textureFileInfo.exists() && textureFileInfo.isFile()) { // set the texture URL to the local texture that we have confirmed exists urlToTexture = QUrl::fromLocalFile(textureFileInfo.absoluteFilePath()); } else { // external texture that we'll need to download or find // this is a relative file path which will require different handling // depending on the location of the original model if (_modelURL.isLocalFile() && apparentRelativePath.exists() && apparentRelativePath.isFile()) { // the absolute path we ran into for the texture in the model exists on this machine // so use that file urlToTexture = QUrl::fromLocalFile(apparentRelativePath.absoluteFilePath()); } else { // we didn't find the texture on this machine at the absolute path // so assume that it is right beside the model to match the behaviour of interface urlToTexture = _modelURL.resolved(apparentRelativePath.fileName()); } } } return urlToTexture; } QString ModelBaker::texturePathRelativeToModel(QUrl modelURL, QUrl textureURL) { auto modelPath = modelURL.toString(QUrl::RemoveFilename | QUrl::RemoveQuery | QUrl::RemoveFragment); auto texturePath = textureURL.toString(QUrl::RemoveFilename | QUrl::RemoveQuery | QUrl::RemoveFragment); if (texturePath.startsWith(modelPath)) { // texture path is a child of the model path, return the texture path without the model path return texturePath.mid(modelPath.length()); } else { // the texture path was not a child of the model path, return the empty string return ""; } } void ModelBaker::checkIfTexturesFinished() { // check if we're done everything we need to do for this model // and emit our finished signal if we're done if (_bakingTextures.isEmpty()) { if (shouldStop()) { // if we're checking for completion but we have errors // that means one or more of our texture baking operations failed if (_pendingErrorEmission) { setIsFinished(true); } return; } else { qCDebug(model_baking) << "Finished baking, emitting finished" << _modelURL; texturesFinished(); setIsFinished(true); } } } QString ModelBaker::createBaseTextureFileName(const QFileInfo& textureFileInfo, const image::TextureUsage::Type textureType) { // If two textures have the same URL but are used differently, we need to process them separately QString addMapChannel = QString::fromStdString("_" + std::to_string(textureType)); QString baseTextureFileName{ textureFileInfo.completeBaseName() + addMapChannel }; // first make sure we have a unique base name for this texture // in case another texture referenced by this model has the same base name auto& nameMatches = _textureNameMatchCount[baseTextureFileName]; if (nameMatches > 0) { // there are already nameMatches texture with this name // append - and that number to our baked texture file name so that it is unique baseTextureFileName += "-" + QString::number(nameMatches); } // increment the number of name matches ++nameMatches; return baseTextureFileName; } void ModelBaker::setWasAborted(bool wasAborted) { if (wasAborted != _wasAborted.load()) { Baker::setWasAborted(wasAborted); if (wasAborted) { qCDebug(model_baking) << "Aborted baking" << _modelURL; } } } void ModelBaker::texturesFinished() { embedTextureMetaData(); exportScene(); } void ModelBaker::embedTextureMetaData() { std::vector embeddedTextureNodes; for (FBXNode& rootChild : _rootNode.children) { if (rootChild.name == "Objects") { qlonglong maxId = 0; for (auto &child : rootChild.children) { if (child.properties.length() == 3) { maxId = std::max(maxId, child.properties[0].toLongLong()); } } for (auto& object : rootChild.children) { if (object.name == "Texture") { QVariant relativeFilename; for (auto& child : object.children) { if (child.name == "RelativeFilename") { relativeFilename = child.properties[0]; break; } } if (relativeFilename.isNull() || !relativeFilename.toString().endsWith(BAKED_META_TEXTURE_SUFFIX)) { continue; } if (object.properties.length() < 2) { qWarning() << "Found texture with unexpected number of properties: " << object.name; continue; } FBXNode videoNode; videoNode.name = "Video"; videoNode.properties.append(++maxId); videoNode.properties.append(object.properties[1]); videoNode.properties.append("Clip"); QString bakedTextureFilePath { _bakedOutputDir + "/" + relativeFilename.toString() }; QFile textureFile { bakedTextureFilePath }; if (!textureFile.open(QIODevice::ReadOnly)) { qWarning() << "Failed to open: " << bakedTextureFilePath; continue; } videoNode.children.append({ "RelativeFilename", { relativeFilename }, { } }); videoNode.children.append({ "Content", { textureFile.readAll() }, { } }); rootChild.children.append(videoNode); textureFile.close(); } } } } } void ModelBaker::exportScene() { auto fbxData = FBXWriter::encodeFBX(_rootNode); QString bakedModelURL = _bakedModelURL.toString(); QFile bakedFile(bakedModelURL); if (!bakedFile.open(QIODevice::WriteOnly)) { handleError("Error opening " + bakedModelURL + " for writing"); return; } bakedFile.write(fbxData); _outputFiles.push_back(bakedModelURL); #ifdef HIFI_DUMP_FBX { FBXToJSON fbxToJSON; fbxToJSON << _rootNode; QFileInfo modelFile(_bakedModelURL.toString()); QString outFilename(modelFile.dir().absolutePath() + "/" + modelFile.completeBaseName() + "_FBX.json"); QFile jsonFile(outFilename); if (jsonFile.open(QIODevice::WriteOnly)) { jsonFile.write(fbxToJSON.str().c_str(), fbxToJSON.str().length()); jsonFile.close(); } } #endif qCDebug(model_baking) << "Exported" << _modelURL << "with re-written paths to" << bakedModelURL; }