fix(dashboard): white pines, see-through hills and fighting surfaces in the 3D views

- Shader 94 ("Basic") draws with vertex colors: its class's technique setup
  names Technique_Basic_Lighting_VertColor, as 38 "Basic VC" does. The views
  treated it as having none, so Nimbus Station's glom pines (tag S84) came
  out as their grey texture, white. The other shader looks in use were
  checked the same way (33, 35, 37, 70, 82, 84, 105 hold).
- Two layer shaders: NiTexturingProperty's dark texture and the UV set each
  texture's flags name are read, and the views draw "Two Layers Blended" as
  the dark texture under the base one by the vertex alpha (no longer as
  opacity) and "Two Textures Added" as in TwoLayersAdded_PS. Avant Gardens'
  snowy grass mounds were see-through hills. The client ships no technique
  for the blended ones, so that blend follows the meshes' data.
- The near plane follows how far out the camera is (distance / 400, 0.5 to
  20) instead of a fixed 0.5 over a far plane in the thousands, so ground
  overlays, floor rings and road pieces stop fighting in far views.
- Conversion format 3 (new model data), so kept conversions are made again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 09:07:35 -05:00
parent 8193e23e53
commit b65220b446
9 changed files with 272 additions and 77 deletions

View File

@@ -248,6 +248,7 @@ namespace {
size_t m_FooterStart{}; size_t m_FooterStart{};
std::set<int32_t> m_Used; std::set<int32_t> m_Used;
std::set<int32_t> m_Visiting; std::set<int32_t> m_Visiting;
std::vector<std::vector<float>> m_UvSets; // the UV sets of the geometry being read
NifFile::Model m_Model; NifFile::Model m_Model;
static bool IsDrawnType(const std::string& type) { static bool IsDrawnType(const std::string& type) {
@@ -448,8 +449,14 @@ namespace {
m_Used.insert(dataRef); m_Used.insert(dataRef);
NifFile::Mesh mesh; NifFile::Mesh mesh;
m_UvSets.clear();
if (!ReadGeometryData(dataRef, *dataType == "NiTriStripsData", parent.Then(av.transform), mesh) || mesh.indices.empty()) return; if (!ReadGeometryData(dataRef, *dataType == "NiTriStripsData", parent.Then(av.transform), mesh) || mesh.indices.empty()) return;
mesh.material = ReadMaterial(properties); uint8_t baseSet = 0, darkSet = 0;
mesh.material = ReadMaterial(properties, baseSet, darkSet);
// Each texture reads the UV set its flags name (TexturingMapFlags' low byte), the first when that's missing
const auto set = [this](uint8_t index) { return index < m_UvSets.size() ? m_UvSets[index] : m_UvSets.empty() ? std::vector<float>{} : m_UvSets[0]; };
mesh.uvs = set(baseSet);
if (!mesh.material.darkTexture.empty() || mesh.material.embeddedDarkTexture >= 0) mesh.uvs2 = set(darkSet);
mesh.material.shaderTag = properties.shaderTag; mesh.material.shaderTag = properties.shaderTag;
if (skin >= 0) { if (skin >= 0) {
m_Model.skinned++; m_Model.skinned++;
@@ -491,10 +498,7 @@ namespace {
for (const auto value : colors) mesh.colors.push_back(static_cast<uint8_t>(std::lround(std::clamp(value, 0.0f, 1.0f) * 255.0f))); for (const auto value : colors) mesh.colors.push_back(static_cast<uint8_t>(std::lround(std::clamp(value, 0.0f, 1.0f) * 255.0f)));
} }
const auto uvSets = dataFlags & 63; const auto uvSets = dataFlags & 63;
if (uvSets > 0) { for (int set = 0; set < uvSets; set++) m_UvSets.push_back(reader.Array<float>(static_cast<uint64_t>(count) * 2));
mesh.uvs = reader.Array<float>(static_cast<uint64_t>(count) * 2);
reader.Skip(static_cast<uint64_t>(uvSets - 1) * count * 8);
}
reader.U16(); // consistency flags reader.U16(); // consistency flags
reader.I32(); // additional data reader.I32(); // additional data
const auto triangles = reader.U16(); const auto triangles = reader.U16();
@@ -519,7 +523,7 @@ namespace {
if (!reader.Ok() || mesh.positions.size() != static_cast<size_t>(count) * 3) return false; if (!reader.Ok() || mesh.positions.size() != static_cast<size_t>(count) * 3) return false;
if (mesh.normals.size() != mesh.positions.size()) mesh.normals.clear(); if (mesh.normals.size() != mesh.positions.size()) mesh.normals.clear();
if (mesh.colors.size() != static_cast<size_t>(count) * 4) mesh.colors.clear(); if (mesh.colors.size() != static_cast<size_t>(count) * 4) mesh.colors.clear();
if (mesh.uvs.size() != static_cast<size_t>(count) * 2) mesh.uvs.clear(); std::erase_if(m_UvSets, [count](const std::vector<float>& set) { return set.size() != static_cast<size_t>(count) * 2; });
// Drop triangles pointing past the vertices // Drop triangles pointing past the vertices
std::vector<uint16_t> valid; std::vector<uint16_t> valid;
valid.reserve(mesh.indices.size()); valid.reserve(mesh.indices.size());
@@ -532,7 +536,25 @@ namespace {
return true; return true;
} }
NifFile::Material ReadMaterial(const Properties& properties) { // A texture slot's NiSourceTexture: an external file name or the block of pixels stored in the file
void ReadSource(int32_t source, std::string& file, int32_t& embedded) {
const auto* type = TypeOf(source);
if (!type || *type != "NiSourceTexture") return;
m_Used.insert(source);
auto texture = BlockReader(source);
ReadNet(texture);
const auto external = texture.U8();
const auto name = String(texture.U32());
const auto pixels = texture.I32();
const auto* pixelType = TypeOf(pixels);
if (texture.Ok() && external == 1) file = name;
else if (texture.Ok() && pixelType && (*pixelType == "NiPixelData" || *pixelType == "NiPersistentSrcTextureRendererData")) {
embedded = pixels;
m_Used.insert(pixels);
}
}
NifFile::Material ReadMaterial(const Properties& properties, uint8_t& baseSet, uint8_t& darkSet) {
NifFile::Material material; NifFile::Material material;
if (properties.material >= 0) { if (properties.material >= 0) {
auto reader = BlockReader(properties.material); auto reader = BlockReader(properties.material);
@@ -578,26 +600,30 @@ namespace {
ReadNet(reader); ReadNet(reader);
reader.U16(); // flags reader.U16(); // flags
reader.U32(); // texture count reader.U32(); // texture count
// TexDesc (nif.xml, 20.1.0.3 on): source, TexturingMapFlags (clamp in bits 12-15, UV set in 0-7), whether a
// texture transform follows (translation, scale, rotation, method, center: 32 bytes)
const auto texDesc = [&reader](int32_t& source, uint16_t& flags) {
source = reader.I32();
flags = reader.U16();
if (reader.U8()) reader.Skip(32);
};
int32_t source = -1;
uint16_t flags = 0;
if (reader.U8()) { // has base texture if (reader.U8()) { // has base texture
const auto source = reader.I32(); texDesc(source, flags);
const auto flags = reader.U16(); if (reader.Ok()) {
const auto* type = TypeOf(source); ReadSource(source, material.texture, material.embeddedTexture);
if (reader.Ok() && type && *type == "NiSourceTexture") {
m_Used.insert(source);
auto texture = BlockReader(source);
ReadNet(texture);
const auto external = texture.U8();
const auto file = String(texture.U32());
const auto pixels = texture.I32();
const auto* pixelType = TypeOf(pixels);
if (texture.Ok() && external == 1) material.texture = file;
else if (texture.Ok() && pixelType && (*pixelType == "NiPixelData" || *pixelType == "NiPersistentSrcTextureRendererData")) {
material.embeddedTexture = pixels;
m_Used.insert(pixels);
}
const auto clamp = (flags >> 12) & 0xF; const auto clamp = (flags >> 12) & 0xF;
material.clampU = clamp == 0 || clamp == 1; material.clampU = clamp == 0 || clamp == 1;
material.clampV = clamp == 0 || clamp == 2; material.clampV = clamp == 0 || clamp == 2;
baseSet = static_cast<uint8_t>(flags & 0xFF);
}
}
if (reader.U8()) { // has dark texture
texDesc(source, flags);
if (reader.Ok()) {
ReadSource(source, material.darkTexture, material.embeddedDarkTexture);
darkSet = static_cast<uint8_t>(flags & 0xFF);
} }
} }
} }
@@ -662,9 +688,12 @@ namespace NifFile {
} }
uint8_t ShaderLookFor(int32_t shader) { uint8_t ShaderLookFor(int32_t shader) {
// By the techniques of each mapShaders row (its label names them: "NL" no lighting, "NT" no texture, "VC" // By the technique each shader class sets up (ShaderManager's factory table at 0x01889608, indexed by gameValue;
// vertex colors), e.g. 38 "Basic VC" is Technique_Basic_Lighting_VertColor, 33 "Basic NL VC NT" // the class's technique setup names it). Checked in the client: 33 and 82 Technique_Basic_NoLighting_VertColor_
// Technique_Basic_NoLighting_VertColor_NoTexture, 32 "Basic NL Material" Technique_Basic_Material_NoLighting // NoTexture, 35 and 84 Technique_Basic_NoLighting_VertColor, 37 Technique_Basic_Lighting_VertColor_NoTexture,
// 38 and 94 Technique_Basic_Lighting_VertColor, 70 Technique_AlphaAsAlpha_UVScrolling_SimpleV_NoLighting_
// AlphaAnim, 105 Technique_TwoLayersBlended_NoLighting_VertColor_UVScrolling. The rest follow their mapShaders
// labels ("NL" no lighting, "NT" no texture, "VC" vertex colors)
switch (shader) { switch (shader) {
// Basic NL Material, Over Everything Material Unlit // Basic NL Material, Over Everything Material Unlit
case 32: case 108: case 32: case 108:
@@ -672,9 +701,8 @@ namespace NifFile {
// Basic NL, Basic NL UVAnim, OneSidedAlpha NL (and skinned), Opaque NL NoFog // Basic NL, Basic NL UVAnim, OneSidedAlpha NL (and skinned), Opaque NL NoFog
case 34: case 36: case 56: case 61: case 83: case 34: case 36: case 56: case 61: case 83:
return UNLIT | NO_VERTEX_COLORS; return UNLIT | NO_VERTEX_COLORS;
// Basic, the lit one without vertex colors // (94 "Basic" is Technique_Basic_Lighting_VertColor like "Basic VC": its shader's technique setup, the vtable
case 94: // slot at +0x90 of the class made at 0x0045f240, names that technique, so it's the usual look)
return NO_VERTEX_COLORS;
// VertColor_NoLight_NoTex_AnimAlpha, VC_NL_NoTex_2D, Basic NL VC NT, OneSidedAlpha NL VC NT (and skinned), // VertColor_NoLight_NoTex_AnimAlpha, VC_NL_NoTex_2D, Basic NL VC NT, OneSidedAlpha NL VC NT (and skinned),
// Basic NL NT, Opaque NL VC NT NoFog // Basic NL NT, Opaque NL VC NT NoFog
case 11: case 16: case 33: case 58: case 63: case 80: case 82: case 11: case 16: case 33: case 58: case 63: case 80: case 82:
@@ -682,12 +710,25 @@ namespace NifFile {
// Basic VC NT, Opaque VC NT NoFog // Basic VC NT, Opaque VC NT NoFog
case 37: case 85: case 37: case 85:
return NO_TEXTURE; return NO_TEXTURE;
// Two Textures Added NL VC AnimUV (TwoLayersAdded_PS in BasicShaders.fx), Two Layers Added VC AnimUV
case 93:
return UNLIT | TWO_LAYERS_ADDED;
case 107:
return TWO_LAYERS_ADDED;
// Two Layers Blended NL VC AnimUV and Two Layers Blended VC AnimUV. The client names techniques for them
// (Technique_TwoLayersBlended_*) that no shader it ships has, so how the game draws them is a guess: the dark
// texture under the base one by the vertex alpha, as the meshes' data suggests (Avant Gardens' snow caps
// and grass fade into rock by it)
case 105:
return UNLIT | TWO_LAYERS_BLENDED;
case 106:
return TWO_LAYERS_BLENDED;
// VertColor_NoLighting_Alpha, VertColorTex_NoLight_AlphaBlend and _AlphaTest, VC_NoLighting_2D, Over // VertColor_NoLighting_Alpha, VertColorTex_NoLight_AlphaBlend and _AlphaTest, VC_NoLighting_2D, Over
// Everything (Unlit), Basic NL VC, LEGO-No Light, OneSidedAlpha NL VC (and skinned), OneSidedAlpha NL // Everything (Unlit), Basic NL VC, LEGO-No Light, OneSidedAlpha NL VC (and skinned), OneSidedAlpha NL
// AnimAlpha, the NoLight scrolling UVs, Opaque NL VC NoFog, Additive NoLight VertColor, Two Textures Added NL // AnimAlpha, the NoLight scrolling UVs, Opaque NL VC NoFog, Additive NoLight VertColor, Distortion (Ocean)
// VC AnimUV, Distortion (Ocean) Unlit, Two Layers Blended NL VC AnimUV // Unlit
case 8: case 10: case 54: case 15: case 23: case 35: case 52: case 57: case 62: case 68: case 70: case 73: case 81: case 8: case 10: case 54: case 15: case 23: case 35: case 52: case 57: case 62: case 68: case 70: case 73: case 81:
case 84: case 87: case 93: case 101: case 105: case 84: case 87: case 101:
return UNLIT; return UNLIT;
default: default:
return 0; return 0;
@@ -703,7 +744,7 @@ namespace NifFile {
return Parser(data, 0).Dds(block, error); return Parser(data, 0).Dds(block, error);
} }
std::string Encode(const Model& model, const std::vector<std::string>& textures) { std::string Encode(const Model& model, const std::vector<std::string>& textures, const std::vector<std::string>& darkTextures) {
std::string body; std::string body;
nlohmann::json meshes = nlohmann::json::array(); nlohmann::json meshes = nlohmann::json::array();
std::vector<std::string> names; std::vector<std::string> names;
@@ -712,19 +753,23 @@ namespace NifFile {
const auto& material = mesh.material; const auto& material = mesh.material;
const auto vertices = mesh.positions.size() / 3; const auto vertices = mesh.positions.size() / 3;
const std::string texture = m < textures.size() ? textures[m] : std::string{}; const std::string texture = m < textures.size() ? textures[m] : std::string{};
int32_t textureIndex = -1; const auto indexOf = [&names](const std::string& name) {
if (!texture.empty()) { if (name.empty()) return -1;
const auto it = std::find(names.begin(), names.end(), texture); const auto it = std::find(names.begin(), names.end(), name);
textureIndex = static_cast<int32_t>(it - names.begin()); const auto index = static_cast<int32_t>(it - names.begin());
if (it == names.end()) names.push_back(texture); if (it == names.end()) names.push_back(name);
} return index;
};
const int32_t textureIndex = indexOf(texture);
const int32_t darkIndex = indexOf(m < darkTextures.size() ? darkTextures[m] : std::string{});
const bool uv2 = darkIndex >= 0 && mesh.uvs2.size() == vertices * 2;
nlohmann::json entry{ nlohmann::json entry{
{"offset", body.size()}, {"vertices", vertices}, {"indices", mesh.indices.size()}, {"offset", body.size()}, {"vertices", vertices}, {"indices", mesh.indices.size()},
{"normals", !mesh.normals.empty()}, {"uv", !mesh.uvs.empty() && textureIndex >= 0}, {"colors", !mesh.colors.empty()}, {"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}, {"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}, {"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} {"shaderTag", material.shaderTag}, {"darkTexture", uv2 ? darkIndex : -1}, {"uv2", uv2}
}; };
Append(body, mesh.positions.data(), mesh.positions.size() * sizeof(float)); Append(body, mesh.positions.data(), mesh.positions.size() * sizeof(float));
if (!mesh.normals.empty()) { if (!mesh.normals.empty()) {
@@ -734,6 +779,7 @@ namespace NifFile {
Pad(body); Pad(body);
} }
if (entry["uv"].get<bool>()) Append(body, mesh.uvs.data(), mesh.uvs.size() * sizeof(float)); if (entry["uv"].get<bool>()) Append(body, mesh.uvs.data(), mesh.uvs.size() * sizeof(float));
if (uv2) Append(body, mesh.uvs2.data(), mesh.uvs2.size() * sizeof(float));
if (!mesh.colors.empty()) Append(body, mesh.colors.data(), mesh.colors.size()); if (!mesh.colors.empty()) Append(body, mesh.colors.data(), mesh.colors.size());
Append(body, mesh.indices.data(), mesh.indices.size() * sizeof(uint16_t)); Append(body, mesh.indices.data(), mesh.indices.size() * sizeof(uint16_t));
Pad(body); Pad(body);

View File

@@ -35,6 +35,10 @@ namespace NifFile {
int32_t embeddedTexture{ -1 }; // else the block with the texture's pixels in the file (EmbeddedTexture) int32_t embeddedTexture{ -1 }; // else the block with the texture's pixels in the file (EmbeddedTexture)
bool clampU{}; bool clampU{};
bool clampV{}; bool clampV{};
// NiTexturingProperty's dark texture (its second slot), which the client's two layer shaders blend or add to
// the base texture; as `texture` and `embeddedTexture`
std::string darkTexture;
int32_t embeddedDarkTexture{ -1 };
int32_t shaderTag{ -1 }; // mapShaders id from a multishader tag in the name of the mesh or a node above it int32_t shaderTag{ -1 }; // mapShaders id from a multishader tag in the name of the mesh or a node above it
}; };
@@ -42,7 +46,8 @@ namespace NifFile {
Material material; Material material;
std::vector<float> positions; // x, y, z per vertex, in the model's space std::vector<float> positions; // x, y, z per vertex, in the model's space
std::vector<float> normals; // empty when the geometry has none std::vector<float> normals; // empty when the geometry has none
std::vector<float> uvs; // u, v per vertex, empty when none std::vector<float> uvs; // u, v per vertex, empty when none: the UV set the base texture names
std::vector<float> uvs2; // the UV set the dark texture names, empty without one
std::vector<uint8_t> colors; // r, g, b, a per vertex (sRGB), empty when none std::vector<uint8_t> colors; // r, g, b, a per vertex (sRGB), empty when none
std::vector<uint16_t> indices; // triangles std::vector<uint16_t> indices; // triangles
}; };
@@ -82,7 +87,11 @@ namespace NifFile {
UNLIT = 1, // no lighting: the colors as they are (the "NoLighting" techniques) UNLIT = 1, // no lighting: the colors as they are (the "NoLighting" techniques)
NO_TEXTURE = 2, // the texture isn't sampled ("NoTexture") NO_TEXTURE = 2, // the texture isn't sampled ("NoTexture")
NO_VERTEX_COLORS = 4, // vertex colors aren't read NO_VERTEX_COLORS = 4, // vertex colors aren't read
MATERIAL_COLOR = 8 // NiMaterialProperty's diffuse color is (the "Material" techniques) MATERIAL_COLOR = 8, // NiMaterialProperty's diffuse color is (the "Material" techniques)
// Two textures (base and dark, each with its UV set): blended by the vertex alpha, which is then no opacity
// ("Two Layers Blended"), or added, weighted by the material's diffuse red and green ("Two Textures Added")
TWO_LAYERS_BLENDED = 16,
TWO_LAYERS_ADDED = 32
}; };
// eShaderLook bits of a shader (mapShaders.gameValue); 0 for the usual lit look and for fixed function // eShaderLook bits of a shader (mapShaders.gameValue); 0 for the usual lit look and for fixed function
@@ -114,10 +123,11 @@ namespace NifFile {
* A model for the browser: a little-endian uint32 with the length of a JSON header, the header (padded with spaces * A model for the browser: a little-endian uint32 with the length of a JSON header, the header (padded with spaces
* to a multiple of 4), then the binary data it describes. Per mesh at "offset": float32 positions (3 per vertex), * to a multiple of 4), then the binary data it describes. Per mesh at "offset": float32 positions (3 per vertex),
* int8 normals (3 per vertex, times 127, padded to 4 bytes) when "normals", float32 UVs (2 per vertex) when "uv", * int8 normals (3 per vertex, times 127, padded to 4 bytes) when "normals", float32 UVs (2 per vertex) when "uv",
* uint8 RGBA colors when "colors", then uint16 indices (padded to 4 bytes). `textures[i]` is where mesh i's texture * float32 dark texture UVs when "uv2", uint8 RGBA colors when "colors", then uint16 indices (padded to 4 bytes).
* is (empty: none); the header lists each once in "textures" and a mesh's "texture" indexes it (-1: none). * `textures[i]` is where mesh i's texture is (empty: none) and `darkTextures[i]` its dark texture; the header
* lists each once in "textures" and a mesh's "texture" and "darkTexture" index it (-1: none).
*/ */
std::string Encode(const Model& model, const std::vector<std::string>& textures); std::string Encode(const Model& model, const std::vector<std::string>& textures, const std::vector<std::string>& darkTextures = {});
/** /**
* A texture stored inside a .nif (NiPixelData or NiPersistentSrcTextureRendererData, block `block`) as a DDS file * A texture stored inside a .nif (NiPixelData or NiPersistentSrcTextureRendererData, block `block`) as a DDS file

View File

@@ -245,8 +245,9 @@ namespace {
* Bump when NifFile's output changes: converted models kept on disk are made again, and the manifests' "format" * 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 * 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. * a week). 2: meshes carry their multishader tag; conversions without it drew glom parts with the LEGO shader.
* 3: dark textures and the UV set each texture names.
*/ */
constexpr uint32_t FORMAT_VERSION = 2; constexpr uint32_t FORMAT_VERSION = 3;
// A zone's lighting (WorldScene::Lighting) for the viewers' shaders // A zone's lighting (WorldScene::Lighting) for the viewers' shaders
nlohmann::json LightingJson(const WorldScene::Lighting& lighting) { nlohmann::json LightingJson(const WorldScene::Lighting& lighting) {
@@ -591,12 +592,17 @@ namespace {
return nullptr; return nullptr;
} }
const auto folder = FolderOf(path); const auto folder = FolderOf(path);
std::vector<std::string> textures; // per mesh: a res path, "#<block>" for one stored in the .nif, or empty // Per mesh: a res path, "#<block>" for one stored in the .nif, or empty; for its base and its dark texture
const auto where = [&folder](int32_t embedded, const std::string& file) {
if (embedded >= 0) return "#" + std::to_string(embedded);
return file.empty() ? std::string{} : FindTexture(folder, file);
};
std::vector<std::string> textures, darkTextures;
for (const auto& mesh : model->meshes) { for (const auto& mesh : model->meshes) {
if (mesh.material.embeddedTexture >= 0) textures.push_back("#" + std::to_string(mesh.material.embeddedTexture)); textures.push_back(where(mesh.material.embeddedTexture, mesh.material.texture));
else textures.push_back(mesh.material.texture.empty() ? std::string{} : FindTexture(folder, mesh.material.texture)); darkTextures.push_back(where(mesh.material.embeddedDarkTexture, mesh.material.darkTexture));
} }
auto encoded = std::make_shared<const std::string>(NifFile::Encode(*model, textures)); auto encoded = std::make_shared<const std::string>(NifFile::Encode(*model, textures, darkTextures));
g_Disk.Store(target, *encoded); g_Disk.Store(target, *encoded);
return encoded; return encoded;
} }

View File

@@ -14,7 +14,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { parseModel, mergeMeshes, linearColors } from '/js/scenery-core.js'; import { parseModel, mergeMeshes, linearColors, nearPlaneFor } from '/js/scenery-core.js';
const GEOMETRY_MAGIC = 0x42473031; // "10GB" const GEOMETRY_MAGIC = 0x42473031; // "10GB"
const MAX_PARALLEL_FETCHES = 6; const MAX_PARALLEL_FETCHES = 6;
@@ -499,6 +499,7 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
scene.add(sun, sun.target); scene.add(sun, sun.target);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 10000); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 10000);
let framedNear = 0.1; // the near plane framing set
const controls = new OrbitControls(camera, renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enableDamping = true;
@@ -557,7 +558,7 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
const size = Math.max(box.getSize(new THREE.Vector3()).length(), minSize); const size = Math.max(box.getSize(new THREE.Vector3()).length(), minSize);
controls.target.copy(center); controls.target.copy(center);
camera.position.copy(center).add(new THREE.Vector3(size * 0.6, size * 0.75, size * 0.6)); camera.position.copy(center).add(new THREE.Vector3(size * 0.6, size * 0.75, size * 0.6));
camera.near = size / 500; camera.near = framedNear = size / 500;
camera.far = size * 50; camera.far = size * 50;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
} }
@@ -750,6 +751,13 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
if (onTick) onTick(dt); if (onTick) onTick(dt);
updateBubbles(now); updateBubbles(now);
controls.update(); controls.update();
// The near plane follows how far out the camera is (depth precision for the scenery far away), but never
// past what framing the models set, so close-ups of small models keep working
const near = Math.min(nearPlaneFor(camera.position.distanceTo(controls.target)), framedNear * 20);
if (Math.abs(near - camera.near) / camera.near > 0.15) {
camera.near = near;
camera.updateProjectionMatrix();
}
renderer.render(scene, camera); renderer.render(scene, camera);
})(); })();

View File

@@ -26,6 +26,10 @@ export function parseModel(buffer) {
mesh.uvs = new Float32Array(buffer, offset, n * 2); mesh.uvs = new Float32Array(buffer, offset, n * 2);
offset += n * 8; offset += n * 8;
} }
if (entry.uv2) {
mesh.uvs2 = new Float32Array(buffer, offset, n * 2);
offset += n * 8;
}
if (entry.colors) { if (entry.colors) {
mesh.colors = new Uint8Array(buffer, offset, n * 4); mesh.colors = new Uint8Array(buffer, offset, n * 4);
offset += n * 4; offset += n * 4;
@@ -63,12 +67,13 @@ export function shaderOf(manifest, asset, mesh) {
} }
// NifFile::eShaderLook bits // NifFile::eShaderLook bits
export const SHADER_LOOK = { UNLIT: 1, NO_TEXTURE: 2, NO_VERTEX_COLORS: 4, MATERIAL_COLOR: 8 }; export const SHADER_LOOK = { UNLIT: 1, NO_TEXTURE: 2, NO_VERTEX_COLORS: 4, MATERIAL_COLOR: 8, TWO_LAYERS_BLENDED: 16, TWO_LAYERS_ADDED: 32 };
/** /**
* How a mesh is drawn under the game's shaders, when the manifest has the zone's lighting: {lit, texture, * How a mesh is drawn under the game's shaders, when the manifest has the zone's lighting: {lit, texture,
* vertexColors, material} — whether the scene's sun and ambient light it, its texture and vertex colors are used, and * vertexColors, material, layers} — whether the scene's sun and ambient light it, its texture and vertex colors are
* whether its NiMaterialProperty colors are (only fixed function and the "Material" shaders use them). Null without * used, whether its NiMaterialProperty colors are (only fixed function and the "Material" shaders use them), and how
* a two layer shader puts its dark texture with the base one ('blended', 'added' or null). Null without
* lighting in the manifest (older servers), for the viewer's own lights. * lighting in the manifest (older servers), for the viewer's own lights.
*/ */
export function gameLook(manifest, asset, mesh) { export function gameLook(manifest, asset, mesh) {
@@ -81,7 +86,8 @@ export function gameLook(manifest, asset, mesh) {
texture: !(bits & SHADER_LOOK.NO_TEXTURE), texture: !(bits & SHADER_LOOK.NO_TEXTURE),
// Fixed function reads them as NiVertexColorProperty says; the shaders always do, unless they have none // Fixed function reads them as NiVertexColorProperty says; the shaders always do, unless they have none
vertexColors: !!(mesh.colors && !(bits & SHADER_LOOK.NO_VERTEX_COLORS) && (!fixedFunction || mesh.vertexColors !== 0)), vertexColors: !!(mesh.colors && !(bits & SHADER_LOOK.NO_VERTEX_COLORS) && (!fixedFunction || mesh.vertexColors !== 0)),
material: fixedFunction || !!(bits & SHADER_LOOK.MATERIAL_COLOR) material: fixedFunction || !!(bits & SHADER_LOOK.MATERIAL_COLOR),
layers: bits & SHADER_LOOK.TWO_LAYERS_BLENDED ? 'blended' : bits & SHADER_LOOK.TWO_LAYERS_ADDED ? 'added' : null
}; };
} }
@@ -95,7 +101,7 @@ export function mergeMeshes(meshes) {
for (const mesh of meshes) { for (const mesh of meshes) {
if (!mesh.vertices || !mesh.indices.length) continue; 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, 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.shaderTag]); mesh.vertexColors, mesh.clampU, mesh.clampV, !!mesh.normals, !!mesh.uvs, !!mesh.colors, mesh.shaderTag, mesh.darkTexture, !!mesh.uvs2]);
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(mesh); groups.get(key).push(mesh);
} }
@@ -107,6 +113,7 @@ export function mergeMeshes(meshes) {
const out = { ...first, vertices, positions: new Float32Array(vertices * 3) }; const out = { ...first, vertices, positions: new Float32Array(vertices * 3) };
if (first.normals) out.normals = new Int8Array(vertices * 3); if (first.normals) out.normals = new Int8Array(vertices * 3);
if (first.uvs) out.uvs = new Float32Array(vertices * 2); if (first.uvs) out.uvs = new Float32Array(vertices * 2);
if (first.uvs2) out.uvs2 = new Float32Array(vertices * 2);
if (first.colors) out.colors = new Uint8Array(vertices * 4); if (first.colors) out.colors = new Uint8Array(vertices * 4);
out.indices = vertices > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount); out.indices = vertices > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount);
let v = 0, i = 0; let v = 0, i = 0;
@@ -114,6 +121,7 @@ export function mergeMeshes(meshes) {
out.positions.set(m.positions, v * 3); out.positions.set(m.positions, v * 3);
if (out.normals) out.normals.set(m.normals, v * 3); if (out.normals) out.normals.set(m.normals, v * 3);
if (out.uvs) out.uvs.set(m.uvs, v * 2); if (out.uvs) out.uvs.set(m.uvs, v * 2);
if (out.uvs2) out.uvs2.set(m.uvs2, v * 2);
if (out.colors) out.colors.set(m.colors, v * 4); if (out.colors) out.colors.set(m.colors, v * 4);
for (let k = 0; k < m.indices.length; k++) out.indices[i + k] = m.indices[k] + v; for (let k = 0; k < m.indices.length; k++) out.indices[i + k] = m.indices[k] + v;
v += m.vertices; v += m.vertices;
@@ -382,3 +390,13 @@ export function loadedScenes(scenes, scene) {
if (entry) for (const n of entry.neighbours) loaded.add(n); if (entry) for (const n of entry.neighbours) loaded.add(n);
return loaded; return loaded;
} }
/**
* The camera's near plane for a view `distance` from what it looks at. A depth buffer's precision goes with
* near / distance², so a fixed small near plane (0.5) makes coplanar pieces fight (ground overlays, floor rings, road
* pieces) once the camera is far out; the game's own camera stays close to the player. A four hundredth
* of the distance keeps close-ups working and far views steady.
*/
export function nearPlaneFor(distance) {
return Math.min(20, Math.max(0.5, distance / 400));
}

View File

@@ -165,6 +165,23 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
#ifdef USE_MAP #ifdef USE_MAP
diffuseColor.rgb *= texture2D( map, vMapUv ).rgb; diffuseColor.rgb *= texture2D( map, vMapUv ).rgb;
#endif #endif
`;
// Two layer shaders: the dark texture (its own UV set) under the base one by the vertex alpha, or the two added
const TWO_LAYERS_BLENDED_FRAGMENT = `
#ifdef USE_MAP
#ifdef USE_COLOR_ALPHA
float layerMix = vColor.a;
#else
float layerMix = 1.0;
#endif
diffuseColor.rgb *= mix( texture2D( darkMap, vUvDark ).rgb, texture2D( map, vMapUv ).rgb, layerMix );
#endif
`;
const TWO_LAYERS_ADDED_FRAGMENT = `
#ifdef USE_MAP
diffuseColor *= texture2D( map, vMapUv ) * layerWeights.x + texture2D( darkMap, vUvDark ) * layerWeights.y;
#endif
`; `;
// The texture alpha mode's change to a fragment shader // The texture alpha mode's change to a fragment shader
@@ -235,14 +252,27 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
* mapping, the zone's sun and ambient light per vertex when the shader is lit, the material's color only when the * mapping, the zone's sun and ambient light per vertex when the shader is lit, the material's color only when the
* shader reads it. * shader reads it.
*/ */
function gameMaterial(options, mesh, alphaMode, look) { function gameMaterial(options, mesh, alphaMode, look, darkMap = null) {
const material = new THREE.MeshBasicMaterial({ const material = new THREE.MeshBasicMaterial({
...options, ...options,
color: look.material ? options.color : new THREE.Color(1, 1, 1) color: look.material ? options.color : new THREE.Color(1, 1, 1)
}); });
material.toneMapped = false; material.toneMapped = false;
const layers = darkMap ? look.layers : null;
// TwoLayersAdded_PS: base * material diffuse red + dark * material diffuse green (their animations)
const weights = new THREE.Vector2(mesh.diffuse[0], mesh.diffuse[1]);
material.onBeforeCompile = (shader) => { material.onBeforeCompile = (shader) => {
if (options.map) textureAlphaPatch(shader, alphaMode); if (layers) {
shader.uniforms.darkMap = { value: darkMap };
shader.uniforms.layerWeights = { value: weights };
shader.vertexShader = 'attribute vec2 uvDark;\nvarying vec2 vUvDark;\n' +
shader.vertexShader.replace('#include <uv_vertex>', '#include <uv_vertex>\n\tvUvDark = uvDark;');
shader.fragmentShader = 'uniform sampler2D darkMap;\nuniform vec2 layerWeights;\nvarying vec2 vUvDark;\n' + shader.fragmentShader
.replace('#include <map_fragment>', layers === 'blended' ? TWO_LAYERS_BLENDED_FRAGMENT : TWO_LAYERS_ADDED_FRAGMENT)
.replace('#include <color_fragment>', layers === 'blended' ? '#ifdef USE_COLOR_ALPHA\n\tdiffuseColor.rgb *= vColor.rgb;\n#endif' : '#include <color_fragment>');
} else if (options.map) {
textureAlphaPatch(shader, alphaMode);
}
if (!look.lit) return; if (!look.lit) return;
Object.assign(shader.uniforms, gameLights); Object.assign(shader.uniforms, gameLights);
shader.vertexShader = 'uniform vec3 gameLightColor;\nuniform vec3 gameAmbient;\nuniform vec3 gameLightVec;\nvarying vec3 vGameLight;\n' + shader.vertexShader = 'uniform vec3 gameLightColor;\nuniform vec3 gameAmbient;\nuniform vec3 gameLightVec;\nvarying vec3 vGameLight;\n' +
@@ -250,17 +280,19 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
shader.fragmentShader = 'varying vec3 vGameLight;\n' + shader.fragmentShader = 'varying vec3 vGameLight;\n' +
shader.fragmentShader.replace('#include <aomap_fragment>', '#include <aomap_fragment>\n\treflectedLight.indirectDiffuse *= vGameLight;'); shader.fragmentShader.replace('#include <aomap_fragment>', '#include <aomap_fragment>\n\treflectedLight.indirectDiffuse *= vGameLight;');
}; };
material.customProgramCacheKey = () => 'game:' + (options.map ? alphaMode : '') + ':' + look.lit; material.customProgramCacheKey = () => 'game:' + (options.map ? alphaMode : '') + ':' + look.lit + ':' + layers;
return material; return material;
} }
function materialOf(mesh, map, forSky, alphaMode = 'opacity', look = null) { function materialOf(mesh, map, forSky, alphaMode = 'opacity', look = null, darkMap = null) {
// Nearly everything in the game's files has alpha blending switched on; it only shows where something is see- // 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 (only when the object's shader uses the texture's alpha as // 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. // opacity). Blended meshes still write depth, as Gamebryo's default does.
const vertexColors = usesVertexColors(mesh, look); const vertexColors = usesVertexColors(mesh, look);
let vertexAlpha = false; let vertexAlpha = false;
if (vertexColors) for (let i = 3; i < mesh.colors.length && !vertexAlpha; i += 4) vertexAlpha = mesh.colors[i] < 250; // A two layer blend reads the vertex alpha as the mix of its textures, not as opacity
const layersBlended = !!(darkMap && look.layers === 'blended');
if (vertexColors && !layersBlended) for (let i = 3; i < mesh.colors.length && !vertexAlpha; i += 4) vertexAlpha = mesh.colors[i] < 250;
const textureAlpha = alphaMode === 'opacity' && !!(map && map.userData.alpha); const textureAlpha = alphaMode === 'opacity' && !!(map && map.userData.alpha);
// The game's shaders take alpha from the vertex colors and texture only; NiMaterialProperty's is for fixed function // The game's shaders take alpha from the vertex colors and texture only; NiMaterialProperty's is for fixed function
const materialAlpha = look && !look.material ? 1 : mesh.alpha; const materialAlpha = look && !look.material ? 1 : mesh.alpha;
@@ -275,7 +307,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
map: map || null map: map || null
}; };
if (forSky) return useTextureAlpha(new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false }), alphaMode); if (forSky) return useTextureAlpha(new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false }), alphaMode);
if (look) return gameMaterial(options, mesh, alphaMode, look); if (look) return gameMaterial(options, mesh, alphaMode, look, darkMap);
const material = new THREE.MeshStandardMaterial({ ...options, roughness: 0.85, metalness: 0 }); 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); material.emissive.setRGB(mesh.emissive[0], mesh.emissive[1], mesh.emissive[2], THREE.SRGBColorSpace);
return useTextureAlpha(material, alphaMode); return useTextureAlpha(material, alphaMode);
@@ -290,18 +322,21 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
if (!mesh.vertices || !mesh.indices.length) continue; if (!mesh.vertices || !mesh.indices.length) continue;
// The sky keeps its own unlit look // The sky keeps its own unlit look
const look = forSky ? null : gameLook(manifest, asset, mesh); const look = forSky ? null : gameLook(manifest, asset, mesh);
let map = null; const textureOf = async (slot, clampU, clampV) => {
if (mesh.texture >= 0 && mesh.uv && (!look || look.texture)) { const texture = await loadTexture(asset, slot, model.header.textures[slot], lod);
const texture = await loadTexture(asset, mesh.texture, model.header.textures[mesh.texture], lod); if (!texture) return null;
if (texture) { const map = texture.clone(); // shares the image; wrapping differs per mesh
map = texture.clone(); // shares the image; wrapping differs per mesh map.wrapS = clampU ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping;
map.wrapS = mesh.clampU ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping; map.wrapT = clampV ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping;
map.wrapT = mesh.clampV ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping; map.needsUpdate = true;
map.needsUpdate = true; return map;
} };
} const map = mesh.texture >= 0 && mesh.uv && (!look || look.texture) ? await textureOf(mesh.texture, mesh.clampU, mesh.clampV) : null;
// A two layer shader's second texture, on its own UV set
const darkMap = look && look.layers && map && mesh.darkTexture >= 0 && mesh.uvs2 ? await textureOf(mesh.darkTexture, false, false) : null;
const geometry = geometryOf(mesh, look); const geometry = geometryOf(mesh, look);
parts.push({ geometry, material: materialOf(mesh, map, forSky, textureAlphaMode(manifest, asset, mesh), look) }); if (darkMap) geometry.setAttribute('uvDark', new THREE.BufferAttribute(mesh.uvs2, 2));
parts.push({ geometry, material: materialOf(mesh, map, forSky, textureAlphaMode(manifest, asset, mesh), look, darkMap) });
} }
const min = model.header.min, max = model.header.max; 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; const radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2;
@@ -343,6 +378,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
for (const part of parts) { for (const part of parts) {
if (hidden && !part.ghost) part.ghost = new THREE.MeshBasicMaterial({ color: HIDDEN_COLOR, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }); if (hidden && !part.ghost) part.ghost = new THREE.MeshBasicMaterial({ color: HIDDEN_COLOR, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide });
const mesh = new THREE.InstancedMesh(part.geometry, hidden ? part.ghost : part.material, cellInstances.length); const mesh = new THREE.InstancedMesh(part.geometry, hidden ? part.ghost : part.material, cellInstances.length);
mesh.userData.asset = asset; // which of the manifest's models it is, for picking
cellInstances.forEach((instance, i) => { cellInstances.forEach((instance, i) => {
position.set(instance.x, instance.y, instance.z); position.set(instance.x, instance.y, instance.z);
rotation.set(instance.qx, instance.qy, instance.qz, instance.qw); rotation.set(instance.qx, instance.qy, instance.qz, instance.qw);

View File

@@ -19,6 +19,7 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { buildTerrainChunks, TERRAIN_LOOKS } from '/js/lddviewer.js'; import { buildTerrainChunks, TERRAIN_LOOKS } from '/js/lddviewer.js';
import { createScenery } from '/js/scenery.js'; import { createScenery } from '/js/scenery.js';
import { nearPlaneFor } from '/js/scenery-core.js';
import { Track, replayPosition, trailSegments, heatFrames, heatLevel, heatColor, formatSpan, coreBounds, isPlaceholderTerrain } from '/js/world3d-core.js'; import { Track, replayPosition, trailSegments, heatFrames, heatLevel, heatColor, formatSpan, coreBounds, isPlaceholderTerrain } from '/js/world3d-core.js';
const LIVE_DELAY = 1.2; // seconds live players are drawn behind the newest report const LIVE_DELAY = 1.2; // seconds live players are drawn behind the newest report
@@ -76,6 +77,14 @@ const scenery = createScenery({
onScenes: renderScenes onScenes: renderScenes
}); });
// The near plane follows how far out the camera is, so far views keep their depth precision (no fighting surfaces)
function fitNearPlane(cam, target) {
const near = nearPlaneFor(cam.position.distanceTo(target));
if (Math.abs(near - cam.near) / cam.near < 0.15) return;
cam.near = near;
cam.updateProjectionMatrix();
}
// ---- scenes: which of the zone's scenes the scenery shows (scenery.js setSceneMode) ---- // ---- scenes: which of the zone's scenes the scenery shows (scenery.js setSceneMode) ----
function renderScenes(info) { function renderScenes(info) {
@@ -1112,6 +1121,7 @@ function animate() {
} }
} }
controls.update(); controls.update();
fitNearPlane(camera, controls.target);
scenery.update(dt); scenery.update(dt);
flairs.update(dt); flairs.update(dt);
renderer.render(scene, camera); renderer.render(scene, camera);

View File

@@ -213,6 +213,56 @@ TEST(NifFileTests, PassesPropertiesDownTheTree) {
EXPECT_TRUE(m.doubleSided); EXPECT_TRUE(m.doubleSided);
} }
// Two layer shaders use NiTexturingProperty's dark texture too, each texture on the UV set its flags name
TEST(NifFileTests, ReadsTheDarkTextureAndEachTexturesUvSet) {
NifBuilder nif;
const auto snow = nif.String("snow.dds"), rock = nif.String("rock.dds");
const auto source = [&nif](int32_t name) {
return nif.Add("NiSourceTexture", Net().Put<uint8_t>(1).Put(name).Put<int32_t>(-1).Floats({ 0, 0, 0 }).Put<uint8_t>(1).Put<uint8_t>(1).Put<uint8_t>(0));
};
const auto base = source(snow), dark = source(rock);
// Base on UV set 1 with a texture transform (32 bytes to skip), dark on UV set 0
auto texturing = Net().Put<uint16_t>(0).Put<uint32_t>(9).Put<uint8_t>(1).Put(base).Put<uint16_t>(0x3201).Put<uint8_t>(1);
texturing.Raw(std::string(32, '\0'));
texturing.Put<uint8_t>(1).Put(dark).Put<uint16_t>(0x3200).Put<uint8_t>(0);
for (int slot = 2; slot < 9; slot++) texturing.Put<uint8_t>(0);
texturing.Put<uint32_t>(0);
const auto property = nif.Add("NiTexturingProperty", texturing);
// A triangle with two UV sets: set 0 all (0.25, 0.75), set 1 all (0.5, 0.5)
Bytes data;
data.Put<int32_t>(0).Put<uint16_t>(3).Put<uint8_t>(0).Put<uint8_t>(0).Put<uint8_t>(1);
for (int i = 0; i < 3; i++) data.Floats({ static_cast<float>(i == 1), static_cast<float>(i == 2), 0.0f });
data.Put<uint16_t>(2).Put<uint8_t>(0).Floats({ 0, 0, 0, 1 }).Put<uint8_t>(0);
for (int i = 0; i < 3; i++) data.Floats({ 0.25f, 0.75f });
for (int i = 0; i < 3; i++) data.Floats({ 0.5f, 0.5f });
data.Put<uint16_t>(0).Put<int32_t>(-1).Put<uint16_t>(1).Put<uint32_t>(3).Put<uint8_t>(1).Put<uint16_t>(0).Put<uint16_t>(1).Put<uint16_t>(2).Put<uint16_t>(0);
const auto root = nif.Add("NiNode", {});
const auto shape = nif.Add("NiTriShape", {});
const auto shapeData = nif.Add("NiTriShapeData", data);
nif.Set(root, Node(Av(0, { 0, 0, 0 }, IDENTITY, 1.0f, {}), { shape }));
nif.Set(shape, Geometry(Av(0, { 0, 0, 0 }, IDENTITY, 1.0f, { property }), shapeData));
std::string error;
const auto model = NifFile::Parse(nif.Build({ root }), 0, error);
ASSERT_TRUE(model) << error;
ASSERT_EQ(model->meshes.size(), 1u);
const auto& mesh = model->meshes[0];
EXPECT_EQ(mesh.material.texture, "snow.dds");
EXPECT_EQ(mesh.material.darkTexture, "rock.dds");
ASSERT_EQ(mesh.uvs.size(), 6u);
EXPECT_FLOAT_EQ(mesh.uvs[0], 0.5f); // the base texture's set 1
ASSERT_EQ(mesh.uvs2.size(), 6u);
EXPECT_FLOAT_EQ(mesh.uvs2[1], 0.75f); // the dark texture's set 0
// The browser gets the dark texture and its UVs
const auto encoded = NifFile::Encode(*model, { "mesh/snow.dds" }, { "mesh/rock.dds" });
uint32_t length{};
std::memcpy(&length, encoded.data(), 4);
const auto header = nlohmann::json::parse(encoded.substr(4, length));
EXPECT_EQ(header["textures"], nlohmann::json::array({ "mesh/snow.dds", "mesh/rock.dds" }));
EXPECT_EQ(header["meshes"][0]["darkTexture"], 1);
EXPECT_EQ(header["meshes"][0]["uv2"], true);
}
TEST(NifFileTests, TurnsStripsIntoTriangles) { TEST(NifFileTests, TurnsStripsIntoTriangles) {
NifBuilder nif; NifBuilder nif;
const auto root = nif.Add("NiNode", {}); const auto root = nif.Add("NiNode", {});
@@ -434,6 +484,8 @@ TEST(WorldSceneTests, LightsAZoneAsMostOfItsObjectsAre) {
TEST(NifFileTests, KnowsWhatEachShaderLeavesOut) { TEST(NifFileTests, KnowsWhatEachShaderLeavesOut) {
EXPECT_EQ(NifFile::ShaderLookFor(38), 0); // Basic VC: lit, textured, vertex colors EXPECT_EQ(NifFile::ShaderLookFor(38), 0); // Basic VC: lit, textured, vertex colors
EXPECT_EQ(NifFile::ShaderLookFor(94), 0); // "Basic" draws with vertex colors too (Nimbus Station's pines)
EXPECT_EQ(NifFile::ShaderLookFor(84), NifFile::UNLIT); // Opaque NL VC NoFog
EXPECT_EQ(NifFile::ShaderLookFor(NifFile::LEGO_SHADER), 0); EXPECT_EQ(NifFile::ShaderLookFor(NifFile::LEGO_SHADER), 0);
EXPECT_EQ(NifFile::ShaderLookFor(-1), 0); // fixed function is lit by Gamebryo EXPECT_EQ(NifFile::ShaderLookFor(-1), 0); // fixed function is lit by Gamebryo
EXPECT_EQ(NifFile::ShaderLookFor(33), NifFile::UNLIT | NifFile::NO_TEXTURE); // Basic NL VC NT EXPECT_EQ(NifFile::ShaderLookFor(33), NifFile::UNLIT | NifFile::NO_TEXTURE); // Basic NL VC NT

View File

@@ -33,13 +33,13 @@ same(S.textureAlphaMode(manifest, 1, { shaderTag: 1 }), 'decal', 'LEGO part text
same(S.textureAlphaMode(manifest, 1, { shaderTag: 30 }), 'opacity', 'Basic VC part texture alpha'); same(S.textureAlphaMode(manifest, 1, { shaderTag: 30 }), 'opacity', 'Basic VC part texture alpha');
// Lit, textured, vertex colors, no material colors: Basic VC // Lit, textured, vertex colors, no material colors: Basic VC
same(S.gameLook(manifest, 1, { ...colored, shaderTag: 30 }), { lit: true, texture: true, vertexColors: true, material: false }, 'Basic VC'); same(S.gameLook(manifest, 1, { ...colored, shaderTag: 30 }), { lit: true, texture: true, vertexColors: true, material: false, layers: null }, 'Basic VC');
// Vertex colors are read even when NiVertexColorProperty ignores them, but only if the mesh has some // Vertex colors are read even when NiVertexColorProperty ignores them, but only if the mesh has some
same(S.gameLook(manifest, 0, { ...colored, vertexColors: 0 }).vertexColors, true, 'shader reads vertex colors'); same(S.gameLook(manifest, 0, { ...colored, vertexColors: 0 }).vertexColors, true, 'shader reads vertex colors');
same(S.gameLook(manifest, 0, {}).vertexColors, false, 'mesh without vertex colors'); same(S.gameLook(manifest, 0, {}).vertexColors, false, 'mesh without vertex colors');
same(S.gameLook(manifest, 3, colored), { lit: false, texture: false, vertexColors: true, material: false }, 'Basic NL VC NT'); same(S.gameLook(manifest, 3, colored), { lit: false, texture: false, vertexColors: true, material: false, layers: null }, 'Basic NL VC NT');
// Fixed function: NiVertexColorProperty and the material decide // Fixed function: NiVertexColorProperty and the material decide
same(S.gameLook(manifest, 2, { ...colored, vertexColors: 0 }), { lit: true, texture: true, vertexColors: false, material: true }, 'fixed function'); same(S.gameLook(manifest, 2, { ...colored, vertexColors: 0 }), { lit: true, texture: true, vertexColors: false, material: true, layers: null }, 'fixed function');
// Without the zone's lighting the viewer lights scenery itself // Without the zone's lighting the viewer lights scenery itself
same(S.gameLook({ ...manifest, lighting: null }, 0, colored), null, 'no lighting'); same(S.gameLook({ ...manifest, lighting: null }, 0, colored), null, 'no lighting');
@@ -57,6 +57,15 @@ same([...S.loadedScenes(scenes, 0)], [0], 'loaded in the global scene');
// A run longer than the map stops at its end // A run longer than the map stops at its end
same(S.decodeSceneMap({ chunks: [{ x: 0, z: 0, maxX: 1, maxZ: 1, size: 1, runs: runs([9, 4]) }] }).chunks[0].cells.length, 1, 'runs clipped'); same(S.decodeSceneMap({ chunks: [{ x: 0, z: 0, maxX: 1, maxZ: 1, size: 1, runs: runs([9, 4]) }] }).chunks[0].cells.length, 1, 'runs clipped');
// Two layer shaders
const layered = { ...manifest, shaders: [106, 107], shaderLooks: { 106: S.SHADER_LOOK.TWO_LAYERS_BLENDED, 107: S.SHADER_LOOK.TWO_LAYERS_ADDED } };
same(S.gameLook(layered, 0, colored).layers, 'blended', 'two layers blended');
same(S.gameLook(layered, 1, colored).layers, 'added', 'two layers added');
same(S.gameLook(manifest, 0, colored).layers, null, 'one layer');
// The near plane grows with the distance, within limits
same([S.nearPlaneFor(10), S.nearPlaneFor(2000), S.nearPlaneFor(100000)], [0.5, 5, 20], 'near plane');
if (failures) { if (failures) {
console.error(`${failures} failed`); console.error(`${failures} failed`);
process.exit(1); process.exit(1);