fix(dashboard): texture alpha in 3D scenery follows the game's shaders

The viewer treated every blended texture's alpha as opacity, so models
whose shader uses the alpha for something else rendered see-through. The
scenery manifest now carries each model's shader (RenderComponent.
shader_id via mapShaders) and the multishader tag table; meshes carry
their S##__ tag, read the way LWOBaseRenderComponent::AddObjectToRenderPipe
does (S%d, else _S%d, outside 3..108 the LEGO shader). Per res/shaders:
the LEGO lighting shaders lerp the texture over the vertex colors
(decal), LEGO items and terrain meshes ignore its alpha, everything else
keeps opacity. Pure rules unit tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 03:43:36 -05:00
parent 2961089d6b
commit 330f02de30
7 changed files with 233 additions and 18 deletions

View File

@@ -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<int32_t> 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<int32_t> 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<int32_t> 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<Model> 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()) {

View File

@@ -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<uint16_t> 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<int32_t> 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<Mesh> meshes;

View File

@@ -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<int32_t, int32_t> 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<uint32_t, RenderInfo> ReadRenderInfos() {
std::unordered_map<uint32_t, RenderInfo> 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<uint32_t>(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<uint32_t>(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<uint32_t>(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<int32_t>& 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<std::string> 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<int32_t> 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<ZoneScenery>(std::move(scenery));
}

View File

@@ -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);
}

View File

@@ -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 <map_fragment>', '').replace('#include <color_fragment>', DECAL_FRAGMENT);
} else {
shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', 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;