From c4d2454e636d2d4651d90b54bdd301890369d7be Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 14:40:46 -0500 Subject: [PATCH] fix(ugc): write NIFs the way the game's own brick models are Compared block by block with res/BrickModels/ndmade (and nif.xml for 20.3.0.9, LU's version, user version 0): every shape now has the same four properties in the same order (material with glossiness 4, alpha blending by the vertex alpha, specular off, vertex colors as ambient and diffuse), nodes have flags 0x110 and shapes 0x10. A generated NIF put in place of a game model and spawned in the 1.10.64 client renders with its colors. Readers treat a blended shape as transparent only where it is see-through (material or vertex alpha below 1), for the icons and the 3D view. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/static/js/ugc-viewer.js | 7 +++-- dUgcServer/UgcFormats.cpp | 37 ++++++++++++++---------- dUgcServer/UgcModel.cpp | 5 +++- tests/dUgcTests/UgcTests.cpp | 5 +++- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/dDashboardServer/static/js/ugc-viewer.js b/dDashboardServer/static/js/ugc-viewer.js index 3678f5b1f..7fbd6cd4f 100644 --- a/dDashboardServer/static/js/ugc-viewer.js +++ b/dDashboardServer/static/js/ugc-viewer.js @@ -113,9 +113,12 @@ export function createNifViewer(container) { geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1)); if (!mesh.normals) geometry.computeVertexNormals(); const baseColor = new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace); - const material = new THREE.MeshStandardMaterial({ color: baseColor.clone(), vertexColors: hasColors, transparent: !!mesh.blend, roughness: 0.6, metalness: 0 }); + // Blending only matters where something is see-through (every shape of a brick model blends) + let seeThrough = !!mesh.blend && mesh.alpha < 0.99; + if (mesh.blend && hasColors) for (let i = 3; i < mesh.colors.length && !seeThrough; i += 4) seeThrough = mesh.colors[i] < 250; + const material = new THREE.MeshStandardMaterial({ color: baseColor.clone(), vertexColors: hasColors, transparent: seeThrough, roughness: 0.6, metalness: 0 }); const object = new THREE.Mesh(geometry, material); - if (mesh.blend) object.renderOrder = 1; + if (seeThrough) object.renderOrder = 1; root.add(object); parts.push({ mesh: object, hasColors, baseColor }); triangles += mesh.indices.length / 3; diff --git a/dUgcServer/UgcFormats.cpp b/dUgcServer/UgcFormats.cpp index af67dad5c..eb5368c48 100644 --- a/dUgcServer/UgcFormats.cpp +++ b/dUgcServer/UgcFormats.cpp @@ -113,7 +113,9 @@ namespace { std::vector m_Strings; }; - constexpr uint16_t AV_FLAGS = 14; // the usual NiAVObject flags (selective update), not hidden + // NiAVObject flags as the game's own brick models (res/BrickModels/ndmade) have them: nodes 0x110, shapes 0x10 + constexpr uint16_t NODE_FLAGS = 0x110; + constexpr uint16_t SHAPE_FLAGS = 0x10; void WriteNet(Writer& out, int32_t name) { out.I32(name); @@ -121,9 +123,9 @@ namespace { out.I32(-1); // controller } - void WriteAv(Writer& out, int32_t name, const std::vector& properties) { + void WriteAv(Writer& out, int32_t name, const std::vector& properties, uint16_t flags = NODE_FLAGS) { WriteNet(out, name); - out.U16(AV_FLAGS); + out.U16(flags); for (int i = 0; i < 3; i++) out.Float(0.0f); // translation for (int row = 0; row < 3; row++) { for (int col = 0; col < 3; col++) out.Float(row == col ? 1.0f : 0.0f); @@ -207,7 +209,7 @@ namespace { for (int i = 0; i < 3; i++) material.Float(1.0f); // diffuse for (int i = 0; i < 3; i++) material.Float(0.0f); // specular for (int i = 0; i < 3; i++) material.Float(0.0f); // emissive - material.Float(10.0f); // glossiness + material.Float(4.0f); // glossiness, as the game's brick models material.Float(1.0f); // alpha m_Material = nif.Add("NiMaterialProperty", std::move(material.Data())); @@ -220,21 +222,25 @@ namespace { // An NiTriShape of `mesh` (-1 when it is empty or too big for the format) int32_t Shape(const std::string& name, const UgcModel::Mesh* mesh, bool transparent) { if (!mesh || mesh->Empty() || mesh->positions.size() > 65535 || mesh->TriangleCount() > 65535) return -1; - std::vector properties{ m_Material, m_VertexColor }; - if (transparent) { - if (m_Alpha < 0) { - Writer alpha; - WriteNet(alpha, -1); - alpha.U16(1 | (6 << 1) | (7 << 5)); // blend source alpha over one minus source alpha - alpha.U8(0); - m_Alpha = m_Nif.Add("NiAlphaProperty", std::move(alpha.Data())); - } - properties.push_back(m_Alpha); + // The properties every shape of the game's own brick models has, in their order: material, alpha (blending + // by the vertex alpha: 1 on opaque bricks), specular (off) and vertex colors + if (m_Alpha < 0) { + Writer alpha; + WriteNet(alpha, -1); + alpha.U16(0x00ED); // blend source alpha over one minus source alpha, as the game's files + alpha.U8(0); + m_Alpha = m_Nif.Add("NiAlphaProperty", std::move(alpha.Data())); + Writer specular; + WriteNet(specular, -1); + specular.U16(0); // off + m_Specular = m_Nif.Add("NiSpecularProperty", std::move(specular.Data())); } + (void)transparent; + std::vector properties{ m_Material, m_Alpha, m_Specular, m_VertexColor }; const auto shapeBlock = m_Nif.Reserve("NiTriShape"); const auto dataBlock = m_Nif.Add("NiTriShapeData", TriShapeData(*mesh)); Writer tri; - WriteAv(tri, m_Nif.String(name), properties); + WriteAv(tri, m_Nif.String(name), properties, SHAPE_FLAGS); tri.I32(dataBlock); tri.I32(-1); // skin instance tri.U32(0); // materials @@ -249,6 +255,7 @@ namespace { int32_t m_Material{ -1 }; int32_t m_VertexColor{ -1 }; int32_t m_Alpha{ -1 }; + int32_t m_Specular{ -1 }; }; } diff --git a/dUgcServer/UgcModel.cpp b/dUgcServer/UgcModel.cpp index 60c1c0705..01dcd444e 100644 --- a/dUgcServer/UgcModel.cpp +++ b/dUgcServer/UgcModel.cpp @@ -313,7 +313,10 @@ namespace UgcModel { normal = length > 0.0f ? normal / length : glm::vec3(0.0f, 1.0f, 0.0f); } } - (source.material.alphaBlend ? model.transparent : model.opaque).Append(mesh); + // Blending only shows where something is see-through (the game's brick models blend every shape) + bool seeThrough = source.material.alphaBlend && source.material.alpha < 0.99f; + for (size_t v = 0; source.material.alphaBlend && !seeThrough && v < mesh.colors.size(); v++) seeThrough = mesh.colors[v].a < 0.99f; + (seeThrough ? model.transparent : model.opaque).Append(mesh); } return model; } diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index 8a83ff35b..ac36af8b2 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -217,8 +217,11 @@ TEST(UgcFormats, NifReadsBack) { ASSERT_EQ(model->meshes.size(), 2u); EXPECT_EQ(model->meshes[0].indices.size(), 3u); EXPECT_EQ(model->meshes[0].colors[0], 255); - EXPECT_FALSE(model->meshes[0].material.alphaBlend); + // Every shape blends by its vertex alpha, as the game's own brick models do + EXPECT_TRUE(model->meshes[0].material.alphaBlend); + EXPECT_EQ(model->meshes[0].colors[3], 255); EXPECT_TRUE(model->meshes[1].material.alphaBlend); + EXPECT_EQ(UgcModel::FromNif(*model).transparent.TriangleCount(), 1u); EXPECT_EQ(model->meshes[1].colors[3], 128); EXPECT_EQ(model->meshes[0].material.vertexColorMode, 2); EXPECT_TRUE(model->nodes.contains("SceneNode_Model"));