fix(dashboard): scenery models keep their colors, lit like the game

Glom models (multishader) drew their trees, rocks, fences and water white.
Their conversions kept on disk from before meshes carried their multishader
tag were still served (the cache format version was never bumped), so every
part fell back to the LEGO shader, whose texture alpha lays the (mostly grey)
texture over the vertex colors that hold the actual colors. Browsers also
kept those models for a week.

- Bump the conversion format so old conversions are made again, and put it
  in the manifests; the viewers add it to model and texture URLs.
- Light scenery as the client's shaders do: the scene's sun and ambient
  light from its .lvl (read as level_read_lighting_info, 0x0102f8f0, and
  EnvironmentManager::SetLightEnv, 0x01088aa0, do), per vertex, clamped,
  instead of the view's own lights, environment map and tone mapping. The
  zone takes the lighting most of its objects' scenes have.
- Programmable shaders read vertex colors and ignore NiMaterialProperty's
  color and alpha; unlit and untextured shaders (by mapShaders gameValue)
  leave out lighting or the texture. Fixed function stays as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 21:38:43 -05:00
parent f252c6ff26
commit c0054d8fa3
9 changed files with 456 additions and 31 deletions

View File

@@ -222,8 +222,14 @@ namespace {
};
for (const auto& [id, value] : g_ShaderValues) mode(value);
mode(NifFile::LEGO_SHADER);
// What each shader leaves out of the lit look (NifFile::eShaderLook bits), for the ones that do
nlohmann::json looks = nlohmann::json::object();
for (const auto& [id, value] : g_ShaderValues) {
if (const auto look = NifFile::ShaderLookFor(value)) looks[std::to_string(value)] = look;
}
manifest["shaderTags"] = std::move(tags);
manifest["textureAlpha"] = std::move(modes);
manifest["shaderLooks"] = std::move(looks);
manifest["multishader"] = NifFile::MULTISHADER;
manifest["defaultShader"] = NifFile::LEGO_SHADER;
}
@@ -234,6 +240,23 @@ namespace {
double Round(float value, double scale) { return std::round(static_cast<double>(value) * scale) / scale; }
/**
* Bump when NifFile's output changes: converted models kept on disk are made again, and the manifests' "format"
* goes into the viewers' model and texture URLs so browsers don't keep drawing the old ones (they're cached for
* a week). 2: meshes carry their multishader tag; conversions without it drew glom parts with the LEGO shader.
*/
constexpr uint32_t FORMAT_VERSION = 2;
// A zone's lighting (WorldScene::Lighting) for the viewers' shaders
nlohmann::json LightingJson(const WorldScene::Lighting& lighting) {
const auto triple = [](const std::array<float, 3>& value) { return nlohmann::json{ Round(value[0], 1000.0), Round(value[1], 1000.0), Round(value[2], 1000.0) }; };
return {
{"ambient", triple(lighting.ambient)}, {"light", triple(lighting.light)}, {"lightVec", triple(lighting.lightVec)},
{"upperHemi", triple(lighting.upperHemi)}, {"fogColor", triple(lighting.fogColor)},
{"fogNear", Round(lighting.fogNear, 10.0)}, {"fogFar", Round(lighting.fogFar, 10.0)}
};
}
/**
* A zone's models and scenery manifest, built once (g_Zones). Once shared, assets, index, flairModels and the
* warmed flags are guarded by g_ZoneMutex: the flairs' models join assets when their manifest is built.
@@ -242,6 +265,7 @@ namespace {
std::vector<std::string> assets; // res paths of models, indexed by the manifests
std::map<std::string, size_t> index; // res path -> its index in assets
std::string json;
nlohmann::json lighting; // LightingJson of the zone's lighting, null when its scenes have none
std::unordered_set<std::string> flairModels; // res paths of the flairs' models, converted ahead of others
bool warmedScenery{}; // WarmUp queued the scenery's models
bool warmedFlairs{}; // and the flairs'
@@ -266,9 +290,12 @@ namespace {
nlohmann::json hidden = nlohmann::json::array();
std::vector<int32_t> assetShaders;
int64_t sky = -1;
std::vector<std::pair<WorldScene::Lighting, size_t>> sceneLighting; // each scene's, with how many objects it has
for (const auto& scene : ZonePaths::ReadSceneFiles(*luz)) {
const auto lvl = ClientAssets::ReadResFile("maps/" + folder + scene);
if (!lvl) continue;
const auto objectsBefore = assetOf.size();
const auto lighting = WorldScene::ReadLighting(*lvl);
if (sky < 0) {
const auto skydome = JoinPath("", WorldScene::ReadSkydome(*lvl));
if (skydome.ends_with(".nif") && Files().paths.contains(skydome)) sky = static_cast<int64_t>(scenery.IndexOf(skydome));
@@ -286,9 +313,11 @@ namespace {
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));
}
if (lighting) sceneLighting.emplace_back(*lighting, assetOf.size() - objectsBefore);
}
if (const auto lighting = WorldScene::ZoneLighting(sceneLighting)) scenery.lighting = LightingJson(*lighting);
nlohmann::json manifest{
{"zone", zoneId}, {"sky", sky}, {"assets", scenery.assets},
{"zone", zoneId}, {"sky", sky}, {"assets", scenery.assets}, {"lighting", scenery.lighting}, {"format", FORMAT_VERSION},
{"objects", { {"asset", assetOf}, {"pos", positions}, {"rot", rotations}, {"scale", scales}, {"hidden", hidden} }}
};
assetShaders.resize(scenery.assets.size(), -1);
@@ -369,7 +398,7 @@ namespace {
assets = scenery.assets;
}
return nlohmann::json{
{"zone", zoneId}, {"sky", -1}, {"assets", assets}, {"distance", FLAIR_DISTANCE}, {"colorScale", 1.0 / 63.0},
{"zone", zoneId}, {"sky", -1}, {"assets", assets}, {"distance", FLAIR_DISTANCE}, {"colorScale", 1.0 / 63.0}, {"lighting", scenery.lighting}, {"format", FORMAT_VERSION},
{"objects", { {"asset", assetOf}, {"pos", positions}, {"rot", rotations}, {"scale", scales}, {"color", colors} }}
}.dump();
}
@@ -385,7 +414,6 @@ namespace {
return g_Flairs.Get(zoneId, [zoneId, &scenery] { return BuildFlairs(zoneId, scenery); });
}
constexpr uint32_t FORMAT_VERSION = 1; // bump when NifFile's output changes, so cached files are rebuilt
constexpr uintmax_t DISK_CACHE_BYTES = 512ull * 1024 * 1024;
const std::filesystem::path CACHE_DIR = std::filesystem::path("dDashboardServer") / "scenery_cache";

View File

@@ -2,9 +2,11 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <cstring>
#include <cstdint>
#include <exception>
#include <optional>
#include <sstream>
#include <string>
#include <vector>
@@ -121,6 +123,112 @@ namespace WorldScene {
return {};
}
/**
* A scene's lighting from its environment chunk, as the client's shaders get it (EnvironmentManager::SetLightEnv,
* 0x01088aa0 in client 1.10.64): g_ambientLight, g_lightColor (the sun's color), g_upperHemiLight and g_lightVec,
* the unit vector toward the sun (the file stores the direction the light shines in). Colors are 0..1.
*/
struct Lighting {
std::array<float, 3> ambient{};
std::array<float, 3> specular{};
std::array<float, 3> upperHemi{};
std::array<float, 3> light{}; // the sun's color
std::array<float, 3> lightVec{}; // toward the sun, unit length
std::array<float, 3> fogColor{};
float fogNear{}; // fog at the highest draw distance setting
float fogFar{};
bool operator==(const Lighting&) const = default;
};
/**
* The lighting of a scene file, read as the client's level_read_lighting_info (0x0102f8f0) does, gated by the
* file's version; nullopt when the file has none or ends early. Scenes older than version 36 have no sun color
* (the client keeps black there); ones before 31 no fog.
*/
inline std::optional<Lighting> ReadLighting(const std::string& lvl) {
std::istringstream stream(lvl);
LevelFile level;
try {
level.Read(stream);
} catch (const std::exception&) {
// The chunk headers read before any damage are enough
}
const auto info = level.chunkHeaders.find(LevelFile::FileInfo);
const auto environment = level.chunkHeaders.find(LevelFile::SceneEnviroment);
if (info == level.chunkHeaders.end() || environment == level.chunkHeaders.end()) return std::nullopt;
const auto version = info->second.fileInfo.version;
size_t at = environment->second.startPosition; // the chunk starts with the offset of its lighting
bool ok = true;
const auto u32 = [&]() {
uint32_t value{};
if (at > lvl.size() || lvl.size() - at < 4) {
ok = false;
return value;
}
std::memcpy(&value, lvl.data() + at, 4);
at += 4;
return value;
};
const auto f32 = [&]() {
const auto bits = u32();
float value{};
std::memcpy(&value, &bits, 4);
return value;
};
const auto read3 = [&](std::array<float, 3>& out) { for (auto& value : out) value = f32(); };
at = u32();
if (!ok || at == 0) return std::nullopt;
Lighting lighting;
if (version > 44) f32(); // how long the client blends to it
read3(lighting.ambient);
read3(lighting.specular);
read3(lighting.upperHemi);
std::array<float, 3> direction{};
read3(direction);
if (version > 30) {
if (version < 39) {
lighting.fogNear = f32();
lighting.fogFar = f32();
} else {
// Two draw distance settings (lowest, then highest): fog near and far, post fog solid and fade, static
// and dynamic object distance
for (int i = 0; i < 6; i++) f32();
lighting.fogNear = f32();
lighting.fogFar = f32();
for (int i = 0; i < 4; i++) f32();
if (version > 39) {
const auto cullGroups = u32(); // group id, min, max each
if (!ok || cullGroups > (lvl.size() - at) / 12) return std::nullopt;
at += static_cast<size_t>(cullGroups) * 12;
}
}
read3(lighting.fogColor);
}
if (version > 35) read3(lighting.light);
if (!ok) return std::nullopt;
const auto length = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]);
if (length > 0.0f) for (int i = 0; i < 3; i++) lighting.lightVec[i] = -direction[i] / length;
return lighting;
}
/**
* The lighting to draw a whole zone with: the client blends to each scene's lighting as the player walks in, so a
* view of the zone takes the one most of its objects are lit by (scenes as {lighting, how many objects}; ties go
* to the first). nullopt when no scene has any.
*/
inline std::optional<Lighting> ZoneLighting(const std::vector<std::pair<Lighting, size_t>>& scenes) {
std::vector<std::pair<Lighting, size_t>> totals;
for (const auto& [lighting, objects] : scenes) {
const auto it = std::find_if(totals.begin(), totals.end(), [&lighting](const auto& total) { return total.first == lighting; });
if (it == totals.end()) totals.emplace_back(lighting, objects);
else it->second += objects;
}
if (totals.empty()) return std::nullopt;
return std::max_element(totals.begin(), totals.end(), [](const auto& a, const auto& b) { return a.second < b.second; })->first;
}
/**
* The kinds objects are drawn as, most telling first: an enemy that also offers missions is an enemy. Anything with
* none of these components is "other" (scenery, volumes, markers). Names come from the enum (GameLabels).