diff --git a/dDashboardServer/routes/NifFile.cpp b/dDashboardServer/routes/NifFile.cpp index e41a3703f..41608a974 100644 --- a/dDashboardServer/routes/NifFile.cpp +++ b/dDashboardServer/routes/NifFile.cpp @@ -118,6 +118,7 @@ namespace { int32_t texturing = -1; int32_t vertexColor = -1; int32_t stencil = -1; + int32_t shaderTag = -1; // the nearest multishader tag ("S05__...") on the way down the tree }; struct NetHeader { @@ -125,6 +126,7 @@ namespace { }; struct AvHeader { + std::string name; uint16_t flags{}; Transform transform; std::vector properties; @@ -330,7 +332,7 @@ namespace { AvHeader ReadAv(Reader& reader) { AvHeader av; - ReadNet(reader); + av.name = ReadNet(reader).name; av.flags = reader.U16(); for (auto& value : av.transform.t) value = reader.Float(); // Matrix33 is stored m11, m21, m31, m12, ... (nif.xml): column by column @@ -385,6 +387,7 @@ namespace { const auto world = parent.Then(av.transform); properties = Inherit(properties, av.properties); + if (const auto tag = NifFile::ShaderTag(av.name); tag >= 0) properties.shaderTag = tag; std::vector drawn = children; if (type == "NiSwitchNode" || type == "NiLODNode") { @@ -431,6 +434,7 @@ namespace { const auto skin = reader.I32(); if (!reader.Ok() || (av.flags & APP_CULLED)) return; properties = Inherit(properties, av.properties); + if (const auto tag = NifFile::ShaderTag(av.name); tag >= 0) properties.shaderTag = tag; const auto* dataType = TypeOf(dataRef); if (!dataType || (*dataType != "NiTriShapeData" && *dataType != "NiTriStripsData")) return; m_Used.insert(dataRef); @@ -438,6 +442,7 @@ namespace { NifFile::Mesh mesh; if (!ReadGeometryData(dataRef, *dataType == "NiTriStripsData", parent.Then(av.transform), mesh) || mesh.indices.empty()) return; mesh.material = ReadMaterial(properties); + mesh.material.shaderTag = properties.shaderTag; if (skin >= 0) { m_Model.skinned++; m_Used.insert(skin); @@ -606,6 +611,48 @@ namespace { } namespace NifFile { + int32_t ShaderTag(std::string_view name) { + // The client reads the tag with sscanf: "S%d" at the start of the name, else "_S%d" after the first "_S" + const auto number = [](std::string_view digits) -> int32_t { + size_t i = 0; + while (i < digits.size() && (digits[i] == ' ' || digits[i] == '\t')) i++; + // A signed number names no mapShaders row, like no number at all + if (i >= digits.size() || digits[i] < '0' || digits[i] > '9') return -1; + int32_t value = 0; + for (; i < digits.size() && digits[i] >= '0' && digits[i] <= '9' && value < 100000; i++) value = value * 10 + (digits[i] - '0'); + return value; + }; + if (name.starts_with('S')) { + const auto tag = number(name.substr(1)); + if (tag >= 0) return tag; + } + const auto at = name.find("_S"); + if (at == std::string_view::npos || at + 3 >= name.size()) return -1; + return number(name.substr(at + 2)); + } + + int32_t MultishaderPart(std::optional tagShader) { + return tagShader && *tagShader >= 3 && *tagShader <= 0x6C ? *tagShader : LEGO_SHADER; + } + + eTextureAlpha TextureAlphaFor(int32_t shader) { + switch (shader) { + // LEGOPPLighting: textured alone the texture's alpha is forced to 1; with vertex colors it only lays the + // texture over them (lerp by its alpha) and the vertex alpha is what shows through + case 4: case 5: case 12: case 25: case 27: case 28: case 29: case 30: case 50: case 72: case 88: + // Darkling: the same lay-over; alpha from lighting or the fade + case 75: case 76: case 77: case 102: case 103: case 104: + return eTextureAlpha::DECAL; + // LEGOPPLighting_Item: texture alpha forced to 1, multiplied by the vertex colors + case 31: case 48: + // TerrainMeshLighting_Rim: texture times vertex colors, alpha only the fade + case 3: + return eTextureAlpha::IGNORED; + default: + return eTextureAlpha::OPACITY; + } + } + std::optional Parse(std::string_view data, uint32_t lod, std::string& error) { return Parser(data, lod).Run(error); } @@ -635,7 +682,8 @@ namespace NifFile { {"normals", !mesh.normals.empty()}, {"uv", !mesh.uvs.empty() && textureIndex >= 0}, {"colors", !mesh.colors.empty()}, {"diffuse", Color(material.diffuse)}, {"emissive", Color(material.emissive)}, {"alpha", std::round(material.alpha * 1000.0f) / 1000.0f}, {"blend", material.alphaBlend}, {"test", material.alphaTest ? material.alphaThreshold : -1}, {"doubleSided", material.doubleSided}, - {"vertexColors", material.vertexColorMode}, {"texture", textureIndex}, {"clampU", material.clampU}, {"clampV", material.clampV} + {"vertexColors", material.vertexColorMode}, {"texture", textureIndex}, {"clampU", material.clampU}, {"clampV", material.clampV}, + {"shaderTag", material.shaderTag} }; Append(body, mesh.positions.data(), mesh.positions.size() * sizeof(float)); if (!mesh.normals.empty()) { diff --git a/dDashboardServer/routes/NifFile.h b/dDashboardServer/routes/NifFile.h index b5c4c5f2a..b84d48c2e 100644 --- a/dDashboardServer/routes/NifFile.h +++ b/dDashboardServer/routes/NifFile.h @@ -35,6 +35,7 @@ namespace NifFile { int32_t embeddedTexture{ -1 }; // else the block with the texture's pixels in the file (EmbeddedTexture) bool clampU{}; bool clampV{}; + int32_t shaderTag{ -1 }; // mapShaders id from a multishader tag in the name of the mesh or a node above it }; struct Mesh { @@ -46,6 +47,31 @@ namespace NifFile { std::vector indices; // triangles }; + /** + * What a texture's alpha channel does, which the client's shader decides (res/shaders/*.fx), not the .nif: + * OPACITY it is see-through where the alpha is (Basic, AlphaAsAlpha, fixed function and most others); DECAL the + * texture is laid over the vertex colors by its alpha and the mesh itself stays as opaque as its vertex colors + * (the LEGO lighting shaders); IGNORED the alpha does nothing (LEGO items, terrain meshes). + */ + enum class eTextureAlpha : uint8_t { OPACITY, DECAL, IGNORED }; + + // mapShaders.gameValue of the LEGO shader, the client's default + constexpr int32_t LEGO_SHADER = 5; + // RenderComponent's shader for models whose parts name their own shaders (mapShaders "Multishader") + constexpr int32_t MULTISHADER = 9999; + + /** + * The mapShaders id in a multishader part's name: "S05__TRUNKS" or "rock_S30" (LWOBaseRenderComponent:: + * AddObjectToRenderPipe reads "S%d", else "_S%d"); -1 for none. + */ + int32_t ShaderTag(std::string_view name); + + // The shader (gameValue) a multishader part is drawn with, from its tag's gameValue: outside 3..108 the LEGO shader + int32_t MultishaderPart(std::optional tagShader); + + // What a texture's alpha does under a shader (mapShaders.gameValue); -1 is fixed function (opacity) + eTextureAlpha TextureAlphaFor(int32_t shader); + struct Model { uint32_t version{}; std::vector meshes; diff --git a/dDashboardServer/routes/Scenery.cpp b/dDashboardServer/routes/Scenery.cpp index 706181af5..f49894905 100644 --- a/dDashboardServer/routes/Scenery.cpp +++ b/dDashboardServer/routes/Scenery.cpp @@ -142,16 +142,24 @@ namespace { struct RenderInfo { std::string asset; // RenderComponent.render_asset as stored std::string type; // Objects.type + int32_t shader{ -1 }; // mapShaders.gameValue of RenderComponent.shader_id (-1 fixed function or unknown) }; + // mapShaders: id (what RenderComponent.shader_id and multishader tags name) -> gameValue (the shader drawn) + std::map g_ShaderValues; + // Every LOT with a render component, read once (Objects has no index on id, so it is read whole too) std::unordered_map ReadRenderInfos() { std::unordered_map infos; try { auto row = CDClientDatabase::ExecuteQuery( - "SELECT cr.id, rc.render_asset FROM ComponentsRegistry cr JOIN RenderComponent rc ON rc.id = cr.component_id " - "WHERE cr.component_type = " + std::to_string(RENDER_COMPONENT) + ";"); - for (; !row.eof(); row.nextRow()) infos.try_emplace(static_cast(row.getIntField(0)), RenderInfo{ row.getStringField(1, ""), "" }); + "SELECT cr.id, rc.render_asset, m.gameValue FROM ComponentsRegistry cr JOIN RenderComponent rc ON rc.id = cr.component_id " + "LEFT JOIN mapShaders m ON m.id = rc.shader_id WHERE cr.component_type = " + std::to_string(RENDER_COMPONENT) + ";"); + for (; !row.eof(); row.nextRow()) { + infos.try_emplace(static_cast(row.getIntField(0)), RenderInfo{ row.getStringField(1, ""), "", row.fieldIsNull(2) ? -1 : row.getIntField(2) }); + } + auto shaders = CDClientDatabase::ExecuteQuery("SELECT id, gameValue FROM mapShaders;"); + for (; !shaders.eof(); shaders.nextRow()) g_ShaderValues.try_emplace(shaders.getIntField(0), shaders.getIntField(1)); auto types = CDClientDatabase::ExecuteQuery("SELECT id, type FROM Objects;"); for (; !types.eof(); types.nextRow()) { const auto it = infos.find(static_cast(types.getIntField(0))); @@ -171,6 +179,7 @@ namespace { struct Model { std::string path; // res path of the .nif, empty for none bool hidden{}; // the client doesn't draw it (WorldScene::ClientDraws) + int32_t shader{ -1 }; // RenderInfo::shader }; /** @@ -189,12 +198,34 @@ namespace { const auto& stored = object.nifName.empty() ? info->second.asset : object.nifName; { std::lock_guard lock(mutex); - if (const auto it = resolved.find(stored); it != resolved.end()) return { it->second, draw == WorldScene::eClientDraw::HIDDEN }; + if (const auto it = resolved.find(stored); it != resolved.end()) return { it->second, draw == WorldScene::eClientDraw::HIDDEN, info->second.shader }; } auto path = ResolveModel(stored); std::lock_guard lock(mutex); resolved.try_emplace(stored, path); - return { std::move(path), draw == WorldScene::eClientDraw::HIDDEN }; + return { std::move(path), draw == WorldScene::eClientDraw::HIDDEN, info->second.shader }; + } + + /** + * How the client's shaders use each model's texture alpha, for the viewer: "shaders" gives each asset's shader + * (the first object drawing it wins; -1 fixed function or not an object), "shaderTags" a multishader part's tag -> + * shader, and "textureAlpha" the shaders whose texture alpha isn't opacity (NifFile::TextureAlphaFor). + */ + void AddShaders(nlohmann::json& manifest, const std::vector& assetShaders) { + RenderInfos(); // reads g_ShaderValues + manifest["shaders"] = assetShaders; + nlohmann::json tags = nlohmann::json::object(), modes = nlohmann::json::object(); + for (const auto& [id, value] : g_ShaderValues) tags[std::to_string(id)] = value; + auto mode = [&modes](int32_t value) { + const auto alpha = NifFile::TextureAlphaFor(value); + if (alpha != NifFile::eTextureAlpha::OPACITY) modes[std::to_string(value)] = alpha == NifFile::eTextureAlpha::DECAL ? "decal" : "ignored"; + }; + for (const auto& [id, value] : g_ShaderValues) mode(value); + mode(NifFile::LEGO_SHADER); + manifest["shaderTags"] = std::move(tags); + manifest["textureAlpha"] = std::move(modes); + manifest["multishader"] = NifFile::MULTISHADER; + manifest["defaultShader"] = NifFile::LEGO_SHADER; } std::optional LuzPath(uint32_t zone) { @@ -233,6 +264,7 @@ namespace { ZoneScenery scenery; nlohmann::json assetOf = nlohmann::json::array(), positions = nlohmann::json::array(), rotations = nlohmann::json::array(), scales = nlohmann::json::array(); nlohmann::json hidden = nlohmann::json::array(); + std::vector assetShaders; int64_t sky = -1; for (const auto& scene : ZonePaths::ReadSceneFiles(*luz)) { const auto lvl = ClientAssets::ReadResFile("maps/" + folder + scene); @@ -245,17 +277,23 @@ namespace { // A spawner is drawn as what it spawns, where the client would show it const auto model = ModelFor(object); if (model.path.empty()) continue; - assetOf.push_back(scenery.IndexOf(model.path)); + const auto asset = scenery.IndexOf(model.path); + assetOf.push_back(asset); + if (asset >= assetShaders.size()) assetShaders.resize(asset + 1, -1); + if (assetShaders[asset] == -1) assetShaders[asset] = model.shader; hidden.push_back(model.hidden ? 1 : 0); for (const auto value : { object.x, object.y, object.z }) positions.push_back(Round(value, 100.0)); for (const auto value : { object.qx, object.qy, object.qz, object.qw }) rotations.push_back(Round(value, 10000.0)); scales.push_back(Round(object.scale, 1000.0)); } } - scenery.json = nlohmann::json{ + nlohmann::json manifest{ {"zone", zoneId}, {"sky", sky}, {"assets", scenery.assets}, {"objects", { {"asset", assetOf}, {"pos", positions}, {"rot", rotations}, {"scale", scales}, {"hidden", hidden} }} - }.dump(); + }; + assetShaders.resize(scenery.assets.size(), -1); + AddShaders(manifest, assetShaders); + scenery.json = manifest.dump(); return std::make_shared(std::move(scenery)); } diff --git a/dDashboardServer/static/js/scenery-core.js b/dDashboardServer/static/js/scenery-core.js index 87b9b504a..af49c7a1b 100644 --- a/dDashboardServer/static/js/scenery-core.js +++ b/dDashboardServer/static/js/scenery-core.js @@ -36,6 +36,23 @@ export function parseModel(buffer) { return { header, meshes }; } +/** + * What a mesh's texture alpha does in the game, from the manifest's shader data (Scenery.cpp AddShaders): the + * client's shader decides, not the .nif. 'opacity' see-through where the alpha is; 'decal' the texture is laid over + * the vertex colors by its alpha (LEGO shaders); 'ignored' the alpha does nothing. A multishader model's parts name + * their shader in their node names (mesh.shaderTag); a tag the client can't use falls back to the LEGO shader. + */ +export function textureAlphaMode(manifest, asset, mesh) { + if (!manifest || !manifest.shaders || !manifest.textureAlpha) return 'opacity'; + let shader = manifest.shaders[asset]; + if (shader === undefined || shader === null) return 'opacity'; + if (shader === manifest.multishader) { + const tagged = mesh && mesh.shaderTag >= 0 && manifest.shaderTags ? manifest.shaderTags[mesh.shaderTag] : undefined; + shader = tagged !== undefined && tagged >= 3 && tagged <= 108 ? tagged : manifest.defaultShader; + } + return manifest.textureAlpha[shader] || 'opacity'; +} + /** * Meshes of a model that look the same (texture, colors, blending, sides, attributes) joined into one, so a model * made of many pieces (the zones' "glom" files have over a hundred) costs a few draw calls instead of one per piece. @@ -46,7 +63,7 @@ export function mergeMeshes(meshes) { for (const mesh of meshes) { if (!mesh.vertices || !mesh.indices.length) continue; const key = JSON.stringify([mesh.texture, mesh.diffuse, mesh.emissive, mesh.alpha, mesh.blend, mesh.test, mesh.doubleSided, - mesh.vertexColors, mesh.clampU, mesh.clampV, !!mesh.normals, !!mesh.uvs, !!mesh.colors]); + mesh.vertexColors, mesh.clampU, mesh.clampV, !!mesh.normals, !!mesh.uvs, !!mesh.colors, mesh.shaderTag]); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(mesh); } diff --git a/dDashboardServer/static/js/scenery.js b/dDashboardServer/static/js/scenery.js index e9ab84a21..46286c935 100644 --- a/dDashboardServer/static/js/scenery.js +++ b/dDashboardServer/static/js/scenery.js @@ -9,7 +9,7 @@ * follows the camera, far away, behind everything. */ import * as THREE from 'three'; -import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf } from '/js/scenery-core.js'; +import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf, textureAlphaMode } from '/js/scenery-core.js'; // Per detail level (the property view's 0 high, 1 medium, 2 low): the model LOD, how far objects are drawn, the // largest texture side and a memory budget for geometry and textures @@ -126,12 +126,50 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress return geometry; } - function materialOf(mesh, map, forSky) { + // The game's LEGO shaders lay the texture over the vertex colors by its alpha instead of letting it show through + const DECAL_FRAGMENT = ` +#if defined( USE_COLOR_ALPHA ) + diffuseColor *= vColor; +#elif defined( USE_COLOR ) + diffuseColor.rgb *= vColor; +#endif +#ifdef USE_MAP + vec4 sampledDiffuseColor = texture2D( map, vMapUv ); + #if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA ) + diffuseColor.rgb = mix( diffuseColor.rgb, sampledDiffuseColor.rgb, sampledDiffuseColor.a ); + #else + diffuseColor.rgb *= sampledDiffuseColor.rgb; + #endif +#endif +`; + // ... or leave its alpha out altogether (LEGO items, terrain meshes) + const OPAQUE_MAP_FRAGMENT = ` +#ifdef USE_MAP + diffuseColor.rgb *= texture2D( map, vMapUv ).rgb; +#endif +`; + + function useTextureAlpha(material, mode) { + if (mode === 'opacity' || !material.map) return material; + material.onBeforeCompile = (shader) => { + if (mode === 'decal') { + shader.fragmentShader = shader.fragmentShader.replace('#include ', '').replace('#include ', DECAL_FRAGMENT); + } else { + shader.fragmentShader = shader.fragmentShader.replace('#include ', OPAQUE_MAP_FRAGMENT); + } + }; + material.customProgramCacheKey = () => 'textureAlpha:' + mode; + return material; + } + + function materialOf(mesh, map, forSky, alphaMode = 'opacity') { // Nearly everything in the game's files has alpha blending switched on; it only shows where something is see- - // through: the material, a vertex or the texture. Blended meshes still write depth, as Gamebryo's default does. + // through: the material, a vertex or the texture (only when the object's shader uses the texture's alpha as + // opacity). Blended meshes still write depth, as Gamebryo's default does. let vertexAlpha = false; if (mesh.colors && mesh.vertexColors !== 0) for (let i = 3; i < mesh.colors.length && !vertexAlpha; i += 4) vertexAlpha = mesh.colors[i] < 250; - const seeThrough = mesh.blend && (mesh.alpha < 0.99 || vertexAlpha || !!(map && map.userData.alpha)); + const textureAlpha = alphaMode === 'opacity' && !!(map && map.userData.alpha); + const seeThrough = mesh.blend && (mesh.alpha < 0.99 || vertexAlpha || textureAlpha); const options = { color: new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace), vertexColors: !!(mesh.colors && mesh.vertexColors !== 0), @@ -141,10 +179,10 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress side: mesh.doubleSided ? THREE.DoubleSide : THREE.FrontSide, map: map || null }; - if (forSky) return new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false }); + if (forSky) return useTextureAlpha(new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false }), alphaMode); const material = new THREE.MeshStandardMaterial({ ...options, roughness: 0.85, metalness: 0 }); material.emissive.setRGB(mesh.emissive[0], mesh.emissive[1], mesh.emissive[2], THREE.SRGBColorSpace); - return material; + return useTextureAlpha(material, alphaMode); } async function buildParts(asset, lod, forSky) { @@ -165,7 +203,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress } } const geometry = geometryOf(mesh); - parts.push({ geometry, material: materialOf(mesh, map, forSky) }); + parts.push({ geometry, material: materialOf(mesh, map, forSky, textureAlphaMode(manifest, asset, mesh)) }); } const min = model.header.min, max = model.header.max; const radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; diff --git a/docs/Dashboard.md b/docs/Dashboard.md index d2a6de643..222f80f81 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -964,6 +964,10 @@ first; the detail setting picks the model level of detail, how far objects are d sharpness and a memory budget. Switch it off with *Scenery*. The 3D world view draws the same as its *Models* layer (on by default, Medium detail), plus the terrain's flairs. Converted models are cached in memory (64 MB) and in `dDashboardServer/scenery_cache` next to the server (at most 512 MB); nothing needs ImageMagick. +Whether a texture's alpha makes a model see-through follows the shader the game draws it with (the render component's +shader, or for a multishaded model the `S05__`-style tag in each part's name), not only the .nif: the LEGO shaders +lay the texture over the vertex colors and terrain meshes and LEGO items ignore its alpha, so their textures' alpha +channels (often gloss or leftover masks) don't punch holes in them. Converting a model the caches don't have yet (a big "glom" file takes a moment) happens on a few worker threads, so the dashboard keeps answering everything else meanwhile: the route hands the request to a worker (`Web::Defer`) and diff --git a/tests/dWebTests/NifFileTests.cpp b/tests/dWebTests/NifFileTests.cpp index 8b9f5b07d..4bb940eb6 100644 --- a/tests/dWebTests/NifFileTests.cpp +++ b/tests/dWebTests/NifFileTests.cpp @@ -395,3 +395,47 @@ TEST(NifFileTests, ReadsTheClientsMeshes) { EXPECT_EQ(read, files); EXPECT_GT(withMeshes, files * 9 / 10); } + +TEST(NifFileTests, ReadsMultishaderTagsLikeTheClient) { + EXPECT_EQ(NifFile::ShaderTag("S05__TRUNKS"), 5); + EXPECT_EQ(NifFile::ShaderTag("S30__Rockwall_0"), 30); + EXPECT_EQ(NifFile::ShaderTag("rock_S14"), 14); + EXPECT_EQ(NifFile::ShaderTag("Shadow_S7_glow"), 7); // "S" not followed by a number: the "_S" tag counts + EXPECT_EQ(NifFile::ShaderTag("rock_S"), -1); + EXPECT_EQ(NifFile::ShaderTag("ROCK"), -1); + EXPECT_EQ(NifFile::ShaderTag(""), -1); + // The client draws a part with the LEGO shader when its tag names no usable shader + EXPECT_EQ(NifFile::MultishaderPart(38), 38); + EXPECT_EQ(NifFile::MultishaderPart(2), NifFile::LEGO_SHADER); + EXPECT_EQ(NifFile::MultishaderPart(9999), NifFile::LEGO_SHADER); + EXPECT_EQ(NifFile::MultishaderPart(std::nullopt), NifFile::LEGO_SHADER); +} + +TEST(NifFileTests, KnowsWhichShadersUseTextureAlphaAsOpacity) { + using NifFile::eTextureAlpha; + EXPECT_EQ(NifFile::TextureAlphaFor(NifFile::LEGO_SHADER), eTextureAlpha::DECAL); // LEGOPPLighting: lerp over vertex colors + EXPECT_EQ(NifFile::TextureAlphaFor(31), eTextureAlpha::IGNORED); // LEGO-Item: alpha forced to 1 + EXPECT_EQ(NifFile::TextureAlphaFor(3), eTextureAlpha::IGNORED); // Terrain Mesh Rim Light: alpha is the fade only + EXPECT_EQ(NifFile::TextureAlphaFor(7), eTextureAlpha::OPACITY); // VertColor_Alpha (AlphaAsAlpha) + EXPECT_EQ(NifFile::TextureAlphaFor(38), eTextureAlpha::OPACITY); // Basic VC + EXPECT_EQ(NifFile::TextureAlphaFor(14), eTextureAlpha::OPACITY); // LEGO Masked NonDecal: texture alpha is output + EXPECT_EQ(NifFile::TextureAlphaFor(53), eTextureAlpha::OPACITY); // LEGO-Emissive lets texture alpha through + EXPECT_EQ(NifFile::TextureAlphaFor(-1), eTextureAlpha::OPACITY); // fixed function: NiAlphaProperty as Gamebryo does +} + +TEST(NifFileTests, PassesMultishaderTagsDownToMeshes) { + NifBuilder nif; + auto rootAv = Av(0, { 0, 0, 0 }, IDENTITY, 1.0f, {}); + const auto name = nif.String("S30__Rockwall_0"); + std::memcpy(rootAv.data.data(), &name, 4); + const auto file = OneTriangle(nif, rootAv); + std::string error; + const auto model = NifFile::Parse(file, 0, error); + ASSERT_TRUE(model) << error; + ASSERT_EQ(model->meshes.size(), 1u); + EXPECT_EQ(model->meshes[0].material.shaderTag, 30); + const auto encoded = NifFile::Encode(*model, { "" }); + uint32_t length{}; + std::memcpy(&length, encoded.data(), 4); + EXPECT_EQ(nlohmann::json::parse(encoded.substr(4, length))["meshes"][0]["shaderTag"], 30); +}