diff --git a/doc/mesh/images/blender-gltf-export.png b/doc/mesh/images/blender-gltf-export.png new file mode 100644 index 0000000000..60fdb53aec Binary files /dev/null and b/doc/mesh/images/blender-gltf-export.png differ diff --git a/doc/mesh/images/blender-outliner.png b/doc/mesh/images/blender-outliner.png new file mode 100644 index 0000000000..7a57cea153 Binary files /dev/null and b/doc/mesh/images/blender-outliner.png differ diff --git a/doc/mesh/multi-object-single-file.md b/doc/mesh/multi-object-single-file.md new file mode 100644 index 0000000000..fcf92d8676 --- /dev/null +++ b/doc/mesh/multi-object-single-file.md @@ -0,0 +1,81 @@ +# Multi-Object Single-File Blender Exports + +The example throughout is a robot arm built as a linkset of ten parts: a base, a turret, an upper arm, a forearm, two wrist joints, a flange and three fingers. Each part has its own levels of detail (LOD) and a physics shape, and the whole arm uploads from one file. + +## How the uploader reads names + +Second Life draws each part with up to four levels of detail (LOD): High up close, then Medium, Low and Lowest further away, plus a physics shape for collisions. The uploader matches each part to its LOD models and physics shape by name: the part's name plus a suffix that says which slot it fills. + +| Slot in the uploader | Suffix | Example | +| --- | --- | --- | +| High (LOD3) | none | `A1_turret` | +| Medium (LOD2) | `_LOD2` | `A1_turret_LOD2` | +| Low (LOD1) | `_LOD1` | `A1_turret_LOD1` | +| Lowest (LOD0) | `_LOD0` | `A1_turret_LOD0` | +| Physics | `_PHYS` | `A1_turret_PHYS` | + +Every part and all its LOD models go in one `.glb`. You pick that one file, and the uploader puts each LOD model and physics shape into its part's slot. Any LOD you leave out is generated from the part. + +- Suffixes are upper case and go at the very end of the name. +- Don't use `_LOD` or `_PHYS` anywhere else in a name. +- No two parts may share a name. + +## Setting up the file + +Keep every part and its LOD models in one `.blend`, export the whole scene to one `.glb`, and pick that file in the uploader. Only the parts are uploaded; their `_LOD2`, `_LOD1`, `_LOD0` and `_PHYS` objects fill the parts' slots. + +The arm's Blender file keeps them in two collections: + +| Collection | Objects | +| --- | --- | +| `TelvanniArm` | the 10 parts: `A0_base`, `A1_turret` … `A7_finger_2` | +| `LODs` (hidden) | each part's LOD models and physics shape: `A1_turret_LOD2`, `A1_turret_LOD1`, `A1_turret_LOD0`, `A1_turret_PHYS`, and the same for every part | + +### Object names, not mesh names + +Each row in the Outliner is two things: the **object** (orange triangle, such as `A1_turret_LOD1`) and, under it, its **mesh data** (green triangle). The uploader reads only the object name. The mesh data is never read, so give it a plain label that can't be mistaken for one. The arm's are `Arm Turret`, `Arm Turret medium`, `Arm Turret low`, `Arm Turret lowest` and `Arm Turret collision`. + +In the screenshot below, the parts are parented in a chain under `TelvanniArm`, each holding its mesh (`Arm Base`, `Arm Turret`, `Upper Arm` …). The hidden `LODs` collection holds every LOD object; `A1_turret_LOD1` holds the mesh `Arm Turret low`. + +![The arm in Blender's Outliner: objects (orange) holding mesh data (green), the LOD models in the hidden LODs collection](images/blender-outliner.png) + +1. Name each part's object plainly (`A1_turret`), and each of its LOD objects with the suffix at the very end (`A1_turret_LOD1`). Delete Blender's `.001` endings. +2. Place each LOD model exactly over its part, with the same bounding box. +3. Export the whole scene with **Limit to** off, so the hidden LOD objects go in too. +4. Pick the `.glb` in **Build > Upload > Model**. The LOD dropdowns read **Load from file**, and the **Log** tab lists the LOD models taken from the file. + +- **Each LOD needs every part.** If only some parts have a `_LOD1`, the Low LOD is generated for all of them, and the log says so. +- **Physics may be partial.** A part with no `_PHYS` gets the uploader's usual physics. +- **A suffixed object needs its part.** `A1_turret_LOD1` with no `A1_turret` in the file uploads as a part of its own. +- Each LOD model needs the same material slots as its part. + +## glTF export settings + +Use **File > Export > glTF 2.0** with these settings; anything not listed can stay at its default. + +| Panel | Setting | Value | +| --- | --- | --- | +| Format | Format | glTF Binary (`.glb`) | +| Include > Limit to | all four | off, so the hidden LOD objects go in too | +| Include > Data | Cameras, Punctual Lights | off | +| Transform | +Y Up | on | +| Data > Mesh | Apply Modifiers | on | +| Data > Mesh | UVs, Normals | on | +| Data > Material | Materials | Export | +| Data > Compression | Draco Compression, Meshopt Compression | off | +| Animation | Animation | off | + +![Blender's glTF 2.0 export settings for a single-file upload](images/blender-gltf-export.png) + +Apply rotation and scale first (Ctrl+A), so the exported shape matches what you see. + +## Common mistakes + +Where each part turns in-world is set by its origin; that has [a guide of its own](object-origins.md). + +| You see | Likely cause | +| --- | --- | +| `A1_turret_LOD2` uploads as a separate part | its part is missing or misspelt, or the name ends in `.001` | +| An LOD you made is generated anyway | not every part has that LOD | +| An LOD looks stretched in-world | its bounding box differs from its part's | +| An LOD is missing from the upload | Limit to was on when exporting, so the hidden LOD objects were left out | diff --git a/indra/llprimitive/lldaeloader.cpp b/indra/llprimitive/lldaeloader.cpp index 2bb02c6c13..f978a955f8 100644 --- a/indra/llprimitive/lldaeloader.cpp +++ b/indra/llprimitive/lldaeloader.cpp @@ -2131,7 +2131,7 @@ void LLDAELoader::processElement( daeElement* element, bool& badElement, DAE* da if (model->mLabel.empty()) { - label = getLodlessLabel(instance_geo); + label = mLod == LLModel::LOD_HIGH ? getElementLabel(instance_geo) : getLodlessLabel(instance_geo); // Mesh uploader LODs by name llassert(!label.empty()); @@ -2146,7 +2146,11 @@ void LLDAELoader::processElement( daeElement* element, bool& badElement, DAE* da { // Don't change model's name if possible, it will play havoc with scenes that already use said model. size_t ext_pos = getSuffixPosition(model->mLabel); - if (ext_pos != std::string::npos) + if (mLod == LLModel::LOD_HIGH) + { + label = model->mLabel; // Mesh uploader LODs by name: the instance is named as its model is + } + else if (ext_pos != std::string::npos) { label = model->mLabel.substr(0, ext_pos); } @@ -2512,7 +2516,8 @@ bool LLDAELoader::loadModelsFromDomMesh(domMesh* mesh, std::vector& mo LLModel* ret = new LLModel(volume_params, 0.f); - std::string model_name = getLodlessLabel(mesh); + // Mesh uploader LODs by name: the high LOD's file keeps its suffixes for the preview to sort out + std::string model_name = mLod == LLModel::LOD_HIGH ? getElementLabel(mesh) : getLodlessLabel(mesh); ret->mLabel = model_name + lod_suffix[mLod]; llassert(!ret->mLabel.empty()); diff --git a/indra/newview/gltf/llgltfloader.cpp b/indra/newview/gltf/llgltfloader.cpp index cb5d97ae9d..304d89b8b4 100644 --- a/indra/newview/gltf/llgltfloader.cpp +++ b/indra/newview/gltf/llgltfloader.cpp @@ -431,7 +431,8 @@ void LLGLTFLoader::processNodeHierarchy(S32 node_idx, std::map // Potentially multiple nodes can reuse the same mesh and Collada used // node name instead of mesh name, so for consistency use node name if // avaliable, node index otherwise. - std::string base_name = getLodlessLabel(node); + // Mesh uploader LODs by name: the high LOD's file keeps its suffixes for the preview to sort out + std::string base_name = mLod == LLModel::LOD_HIGH ? node.mName : getLodlessLabel(node); if (base_name.empty()) { base_name = "node_" + std::to_string(node_idx); diff --git a/indra/newview/llmodelpreview.cpp b/indra/newview/llmodelpreview.cpp index 374821d3e4..15e8467af0 100644 --- a/indra/newview/llmodelpreview.cpp +++ b/indra/newview/llmodelpreview.cpp @@ -287,6 +287,26 @@ void LLModelPreview::rebuildUploadData() assert_main_thread(); mDefaultPhysicsShapeP = NULL; + // Mesh uploader LODs by name: physics set to use a LOD is that LOD as it stands now, however + // it changed since (a new high file, the file's own LODs, generation, a LOD file of its own) + if (mPhysicsSearchLOD >= LLModel::LOD_IMPOSTOR && mPhysicsSearchLOD <= LLModel::LOD_HIGH + && mLODFile[LLModel::LOD_PHYSICS].empty() && !mLODFromHighFile[LLModel::LOD_PHYSICS] + && mModel[LLModel::LOD_PHYSICS] != mModel[mPhysicsSearchLOD]) + { + mModel[LLModel::LOD_PHYSICS] = mModel[mPhysicsSearchLOD]; + mScene[LLModel::LOD_PHYSICS] = mScene[mPhysicsSearchLOD]; + mVertexBuffer[LLModel::LOD_PHYSICS].clear(); + } + // + // Mesh uploader LODs by name: physics from the high file names its default shape as a physics file does + if (mLODFromHighFile[LLModel::LOD_PHYSICS]) + { + LLMatrix4 ignored_transform; + LLModel* default_shape = nullptr; + FindModel(mScene[LLModel::LOD_PHYSICS], DEFAULT_PHYSICS_MESH_NAME + getLodSuffix(LLModel::LOD_PHYSICS), default_shape, ignored_transform); + mDefaultPhysicsShapeP = default_shape; + } + // mUploadData.clear(); mTextureSet.clear(); @@ -885,6 +905,7 @@ void LLModelPreview::setPhysicsFromLOD(S32 lod) if (lod >= 0 && lod <= 3) { mPhysicsSearchLOD = lod; + mLODFromHighFile[LLModel::LOD_PHYSICS] = false; // Mesh uploader LODs by name mModel[LLModel::LOD_PHYSICS] = mModel[lod]; mScene[LLModel::LOD_PHYSICS] = mScene[lod]; mLODFile[LLModel::LOD_PHYSICS].clear(); @@ -1007,6 +1028,7 @@ void LLModelPreview::loadModelCallback(S32 loaded_lod) if (loaded_lod == -1) { //populate all LoDs from model loader scene mSimplifiers.clear(); + std::fill(std::begin(mLODFromHighFile), std::end(mLODFromHighFile), false); // Mesh uploader LODs by name mBaseModel.clear(); mBaseScene.clear(); @@ -1113,6 +1135,16 @@ void LLModelPreview::loadModelCallback(S32 loaded_lod) { //only replace given LoD mModel[loaded_lod] = mModelLoader->mModelList; mScene[loaded_lod] = mModelLoader->mScene; + // Mesh uploader LODs by name: a file of its own replaces what the high file gave + if (loaded_lod != LLModel::LOD_HIGH) + { + mLODFromHighFile[loaded_lod] = false; + } + else + { + splitSuffixedModels(mModelLoader->mFilename); + } + // // Duplicate the model if it is an internal bounding box model if (loaded_lod == LLModel::LOD_PHYSICS && @@ -1839,6 +1871,263 @@ void LLModelPreview::genMeshOptimizerLODs(S32 which_lod, S32 meshopt_mode, U32 d << ", failures: " << meshes_fail_count << LL_ENDL; } +// Mesh uploader LODs by name +void LLModelPreview::splitSuffixedModels(const std::string& filename) +{ + bool was_from_file[LLModel::NUM_LODS]; + std::copy(std::begin(mLODFromHighFile), std::end(mLODFromHighFile), was_from_file); + for (bool& from_file : mLODFromHighFile) + { + from_file = false; + } + // A submodel is named after its root with a letter added, after any suffix: NAME_LOD2b. + auto core_of = [](const LLModel* model) -> std::string + { + return model->mSubmodelID > 0 && !model->mLabel.empty() ? model->mLabel.substr(0, model->mLabel.size() - 1) : model->mLabel; + }; + auto letter_of = [](const LLModel* model) -> std::string + { + return model->mSubmodelID > 0 && !model->mLabel.empty() ? model->mLabel.substr(model->mLabel.size() - 1) : std::string(); + }; + auto suffix_lod = [](const std::string& label) -> S32 + { + for (S32 lod : { (S32)LLModel::LOD_IMPOSTOR, (S32)LLModel::LOD_LOW, (S32)LLModel::LOD_MEDIUM, (S32)LLModel::LOD_PHYSICS }) + { + const std::string suffix = getLodSuffix(lod); + if (label.size() > suffix.size() && label.compare(label.size() - suffix.size(), suffix.size(), suffix) == 0) + { + return lod; + } + } + return -1; + }; + + const bool legacy = gSavedSettings.getBOOL("ImporterLegacyMatching"); + // Only a suffixed model whose base the file also has is a LOD; any other is a model of its own. + std::set bases; + for (const LLPointer& model : mModel[LLModel::LOD_HIGH]) + { + if (model && !model->mSubmodelID && suffix_lod(model->mLabel) < 0) + { + bases.insert(model->mLabel); + } + } + auto lod_of = [&](const LLModel* model) -> S32 + { + if (!model) + { + return -1; + } + const std::string core = core_of(model); + if (legacy) + { + return -1; + } + // The shape for parts with no _PHYS of their own goes to physics whether or not a part shares its name. + if (core == DEFAULT_PHYSICS_MESH_NAME + getLodSuffix(LLModel::LOD_PHYSICS)) + { + return LLModel::LOD_PHYSICS; + } + const S32 lod = suffix_lod(core); + return lod >= 0 && bases.count(stripSuffix(core)) ? lod : -1; + }; + + // LODs an earlier file filled and this one does not are generated again. + LLFloaterModelPreview* fmp = LLFloaterModelPreview::sInstance; + auto release = [&](S32 lod) + { + mModel[lod].clear(); + mScene[lod].clear(); + mLODFile[lod].clear(); + mVertexBuffer[lod].clear(); + if (fmp && lod != LLModel::LOD_PHYSICS) + { + // Only a LOD still set to come from the file goes back to generation; another source stays. + LLComboBox* lod_combo = fmp->findChild("lod_source_" + lod_name[lod]); + if (!lod_combo || lod_combo->getCurrentIndex() == LOD_FROM_FILE) + { + fmp->mLODMode[lod] = MESH_OPTIMIZER_AUTO; + if (lod_combo) + { + lod_combo->setCurrentByIndex(MESH_OPTIMIZER_AUTO); + } + } + fmp->childSetValue("lod_file_" + lod_name[lod], ""); + } + else if (fmp) + { + // Physics the high file no longer supplies is no longer from a file: back to none, as a reset leaves it. + fmp->childSetValue("physics_file", ""); + fmp->childSetValue("physics_lod_combo", "none"); + } + }; + + // A suffixed model with no base is named as the loader always named it, without its suffix. Models + // are found first, so every instance of one shared model takes the new name, not only the first. + std::set leftovers; + for (auto& scene_pair : mScene[LLModel::LOD_HIGH]) + { + for (LLModelInstance& instance : scene_pair.second) + { + LLModel* model = instance.mModel; + if (model && suffix_lod(core_of(model)) >= 0 && lod_of(model) < 0) + { + leftovers.insert(model); + } + } + } + for (LLModel* model : leftovers) + { + model->mLabel = stripSuffix(core_of(model)) + letter_of(model); + } + for (auto& scene_pair : mScene[LLModel::LOD_HIGH]) + { + for (LLModelInstance& instance : scene_pair.second) + { + if (leftovers.count(instance.mModel)) + { + instance.mLabel = instance.mModel->mLabel; // a submodel keeps its letter, so it isn't taken for its root + } + } + } + + // Each model's LOD is settled once, before any is renamed. + std::map moved; + for (const LLPointer& model : mModel[LLModel::LOD_HIGH]) + { + const S32 lod = lod_of(model); + if (lod >= 0) + { + moved[model.get()] = lod; + } + } + auto moved_to = [&](const LLModel* model) -> S32 + { + auto it = moved.find(model); + return it != moved.end() ? it->second : -1; + }; + + bool split[LLModel::NUM_LODS] = {}; + for (const LLPointer& model : mModel[LLModel::LOD_HIGH]) + { + const S32 lod = moved_to(model); + if (lod >= 0 && !split[lod]) + { + split[lod] = true; + mModel[lod].clear(); + mScene[lod].clear(); + } + } + LLModelLoader::model_list kept; + for (const LLPointer& model : mModel[LLModel::LOD_HIGH]) + { + const S32 lod = moved_to(model); + if (lod < 0) + { + kept.push_back(model); + } + else + { + // Named as a separate LOD file would name it, so the matching finds a submodel too. + model->mLabel = stripSuffix(core_of(model)) + letter_of(model) + getLodSuffix(lod); + mModel[lod].push_back(model); + } + } + for (S32 lod = 0; lod < LLModel::NUM_LODS; ++lod) + { + if (was_from_file[lod] && !split[lod]) + { + release(lod); + if (lod != LLModel::LOD_PHYSICS) + { + updateLodControls(lod); + } + } + } + if (kept.size() == mModel[LLModel::LOD_HIGH].size()) + { + return; + } + mModel[LLModel::LOD_HIGH] = kept; + + for (auto scene_it = mScene[LLModel::LOD_HIGH].begin(); scene_it != mScene[LLModel::LOD_HIGH].end();) + { + LLModelLoader::model_instance_list& list = scene_it->second; + for (auto it = list.begin(); it != list.end();) + { + const S32 lod = moved_to(it->mModel); + if (lod < 0) + { + ++it; + continue; + } + mScene[lod][scene_it->first].push_back(*it); + it = list.erase(it); + } + scene_it = list.empty() ? mScene[LLModel::LOD_HIGH].erase(scene_it) : std::next(scene_it); + } + + if (split[LLModel::LOD_PHYSICS]) + { + // As an explicit physics file does: the parts with no _PHYS are logged, and may use a default shape. + mPhysicsSearchLOD = LLModel::LOD_PHYSICS; + mWarnOfUnmatchedPhyicsMeshes = true; + } + + for (S32 lod = 0; lod < LLModel::NUM_LODS; ++lod) + { + if (!split[lod]) + { + continue; + } + // A LOD needs every model and submodel, each by its own name: a partial set is dropped and the LOD generated. + auto complete = [&]() + { + if (mModel[lod].size() != kept.size()) + { + return false; + } + std::set names; + for (const LLPointer& model : mModel[lod]) + { + names.insert(model->mLabel); + } + return std::all_of(kept.begin(), kept.end(), [&](const LLPointer& model) + { return names.count(model->mLabel + getLodSuffix(lod)) > 0; }); + }; + if (lod != LLModel::LOD_PHYSICS && !complete()) + { + release(lod); + updateLodControls(lod); + std::ostringstream out; + out << "Only some models have a " << getLodSuffix(lod) << " model in the file; generating that LOD instead"; + LLFloaterModelPreview::addStringToLog(out, true); + continue; + } + mLODFromHighFile[lod] = true; + mLODFile[lod] = filename; + mVertexBuffer[lod].clear(); + if (fmp) + { + fmp->setCtrlLoadFromFile(lod); + if (lod == LLModel::LOD_PHYSICS) + { + fmp->childSetValue("physics_file", filename); + } + else + { + fmp->mLODMode[lod] = LOD_FROM_FILE; + fmp->childSetValue("lod_file_" + lod_name[lod], filename); + updateLodControls(lod); + } + } + std::ostringstream out; + out << "Took " << mModel[lod].size() << " model(s) named " << getLodSuffix(lod) << " from the file for that slot"; + LLFloaterModelPreview::addStringToLog(out, false); + } +} +// + void LLModelPreview::updateStatusMessages() { // bit mask values for physics errors. used to prevent overwrite of single line status @@ -2699,7 +2988,10 @@ void LLModelPreview::update() for (S32 lod = LLModel::LOD_HIGH; lod >= 0; --lod) { // adding all lods into query for generation - mLodsQuery.push_back(lod); + if (!mLODFromHighFile[lod]) // Mesh uploader LODs by name: the file gave this one + { + mLodsQuery.push_back(lod); + } } if (subscribe_for_generation) diff --git a/indra/newview/llmodelpreview.h b/indra/newview/llmodelpreview.h index b62030a349..14c46d97f6 100644 --- a/indra/newview/llmodelpreview.h +++ b/indra/newview/llmodelpreview.h @@ -172,6 +172,10 @@ class LLModelPreview : public LLViewerDynamicTexture, public LLMutex, public LLH void setHasPivot(bool val) { mHasPivot = val; } void setModelPivot(const LLVector3& pivot) { mModelPivot = pivot; } + // Mesh uploader LODs by name: a file's NAME_LOD0-2 and NAME_PHYS models fill those slots of NAME + void splitSuffixedModels(const std::string& filename); + // + //Is a rig valid so that it can be used as a criteria for allowing for uploading of joint positions //Accessors for joint position upload friendly rigs const bool isRigValidForJointPositionUpload(void) const { return mRigValidJointUpload; } @@ -271,6 +275,7 @@ class LLModelPreview : public LLViewerDynamicTexture, public LLMutex, public LLH bool mResetJoints; bool mModelNoErrors; bool mLookUpLodFiles; + bool mLODFromHighFile[LLModel::NUM_LODS]{}; // Mesh uploader LODs by name std::map mViewOption;