diff --git a/dCommon/NifFile.cpp b/dCommon/NifFile.cpp index 18baa4e5b..0861fcfbb 100644 --- a/dCommon/NifFile.cpp +++ b/dCommon/NifFile.cpp @@ -248,6 +248,7 @@ namespace { size_t m_FooterStart{}; std::set m_Used; std::set m_Visiting; + std::vector> m_UvSets; // the UV sets of the geometry being read NifFile::Model m_Model; static bool IsDrawnType(const std::string& type) { @@ -448,8 +449,14 @@ namespace { m_Used.insert(dataRef); NifFile::Mesh mesh; + m_UvSets.clear(); 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{} : 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; if (skin >= 0) { m_Model.skinned++; @@ -491,10 +498,7 @@ namespace { for (const auto value : colors) mesh.colors.push_back(static_cast(std::lround(std::clamp(value, 0.0f, 1.0f) * 255.0f))); } const auto uvSets = dataFlags & 63; - if (uvSets > 0) { - mesh.uvs = reader.Array(static_cast(count) * 2); - reader.Skip(static_cast(uvSets - 1) * count * 8); - } + for (int set = 0; set < uvSets; set++) m_UvSets.push_back(reader.Array(static_cast(count) * 2)); reader.U16(); // consistency flags reader.I32(); // additional data const auto triangles = reader.U16(); @@ -519,7 +523,7 @@ namespace { if (!reader.Ok() || mesh.positions.size() != static_cast(count) * 3) return false; if (mesh.normals.size() != mesh.positions.size()) mesh.normals.clear(); if (mesh.colors.size() != static_cast(count) * 4) mesh.colors.clear(); - if (mesh.uvs.size() != static_cast(count) * 2) mesh.uvs.clear(); + std::erase_if(m_UvSets, [count](const std::vector& set) { return set.size() != static_cast(count) * 2; }); // Drop triangles pointing past the vertices std::vector valid; valid.reserve(mesh.indices.size()); @@ -532,7 +536,25 @@ namespace { 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; if (properties.material >= 0) { auto reader = BlockReader(properties.material); @@ -578,26 +600,30 @@ namespace { ReadNet(reader); reader.U16(); // flags 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 - const auto source = reader.I32(); - const auto flags = reader.U16(); - const auto* type = TypeOf(source); - 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); - } + texDesc(source, flags); + if (reader.Ok()) { + ReadSource(source, material.texture, material.embeddedTexture); const auto clamp = (flags >> 12) & 0xF; material.clampU = clamp == 0 || clamp == 1; material.clampV = clamp == 0 || clamp == 2; + baseSet = static_cast(flags & 0xFF); + } + } + if (reader.U8()) { // has dark texture + texDesc(source, flags); + if (reader.Ok()) { + ReadSource(source, material.darkTexture, material.embeddedDarkTexture); + darkSet = static_cast(flags & 0xFF); } } } @@ -662,9 +688,12 @@ namespace NifFile { } uint8_t ShaderLookFor(int32_t shader) { - // By the techniques of each mapShaders row (its label names them: "NL" no lighting, "NT" no texture, "VC" - // vertex colors), e.g. 38 "Basic VC" is Technique_Basic_Lighting_VertColor, 33 "Basic NL VC NT" - // Technique_Basic_NoLighting_VertColor_NoTexture, 32 "Basic NL Material" Technique_Basic_Material_NoLighting + // By the technique each shader class sets up (ShaderManager's factory table at 0x01889608, indexed by gameValue; + // the class's technique setup names it). Checked in the client: 33 and 82 Technique_Basic_NoLighting_VertColor_ + // 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) { // Basic NL Material, Over Everything Material Unlit case 32: case 108: @@ -672,9 +701,8 @@ namespace NifFile { // Basic NL, Basic NL UVAnim, OneSidedAlpha NL (and skinned), Opaque NL NoFog case 34: case 36: case 56: case 61: case 83: return UNLIT | NO_VERTEX_COLORS; - // Basic, the lit one without vertex colors - case 94: - return NO_VERTEX_COLORS; + // (94 "Basic" is Technique_Basic_Lighting_VertColor like "Basic VC": its shader's technique setup, the vtable + // slot at +0x90 of the class made at 0x0045f240, names that technique, so it's the usual look) // 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 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 case 37: case 85: 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 // 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 - // VC AnimUV, Distortion (Ocean) Unlit, Two Layers Blended NL VC AnimUV + // AnimAlpha, the NoLight scrolling UVs, Opaque NL VC NoFog, Additive NoLight VertColor, Distortion (Ocean) + // 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 84: case 87: case 93: case 101: case 105: + case 84: case 87: case 101: return UNLIT; default: return 0; @@ -703,7 +744,7 @@ namespace NifFile { return Parser(data, 0).Dds(block, error); } - std::string Encode(const Model& model, const std::vector& textures) { + std::string Encode(const Model& model, const std::vector& textures, const std::vector& darkTextures) { std::string body; nlohmann::json meshes = nlohmann::json::array(); std::vector names; @@ -712,19 +753,23 @@ namespace NifFile { const auto& material = mesh.material; const auto vertices = mesh.positions.size() / 3; const std::string texture = m < textures.size() ? textures[m] : std::string{}; - int32_t textureIndex = -1; - if (!texture.empty()) { - const auto it = std::find(names.begin(), names.end(), texture); - textureIndex = static_cast(it - names.begin()); - if (it == names.end()) names.push_back(texture); - } + const auto indexOf = [&names](const std::string& name) { + if (name.empty()) return -1; + const auto it = std::find(names.begin(), names.end(), name); + const auto index = static_cast(it - names.begin()); + 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{ {"offset", body.size()}, {"vertices", vertices}, {"indices", mesh.indices.size()}, {"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}, - {"shaderTag", material.shaderTag} + {"shaderTag", material.shaderTag}, {"darkTexture", uv2 ? darkIndex : -1}, {"uv2", uv2} }; Append(body, mesh.positions.data(), mesh.positions.size() * sizeof(float)); if (!mesh.normals.empty()) { @@ -734,6 +779,7 @@ namespace NifFile { Pad(body); } if (entry["uv"].get()) 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()); Append(body, mesh.indices.data(), mesh.indices.size() * sizeof(uint16_t)); Pad(body); diff --git a/dCommon/NifFile.h b/dCommon/NifFile.h index a0996dd32..ae3a53ff2 100644 --- a/dCommon/NifFile.h +++ b/dCommon/NifFile.h @@ -35,6 +35,10 @@ namespace NifFile { int32_t embeddedTexture{ -1 }; // else the block with the texture's pixels in the file (EmbeddedTexture) bool clampU{}; 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 }; @@ -42,7 +46,8 @@ namespace NifFile { Material material; std::vector positions; // x, y, z per vertex, in the model's space std::vector normals; // empty when the geometry has none - std::vector uvs; // u, v per vertex, empty when none + std::vector uvs; // u, v per vertex, empty when none: the UV set the base texture names + std::vector uvs2; // the UV set the dark texture names, empty without one std::vector colors; // r, g, b, a per vertex (sRGB), empty when none std::vector indices; // triangles }; @@ -82,7 +87,11 @@ namespace NifFile { UNLIT = 1, // no lighting: the colors as they are (the "NoLighting" techniques) NO_TEXTURE = 2, // the texture isn't sampled ("NoTexture") 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 @@ -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 * 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", - * uint8 RGBA colors when "colors", then uint16 indices (padded to 4 bytes). `textures[i]` is where mesh i's texture - * is (empty: none); the header lists each once in "textures" and a mesh's "texture" indexes it (-1: none). + * float32 dark texture UVs when "uv2", uint8 RGBA colors when "colors", then uint16 indices (padded to 4 bytes). + * `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& textures); + std::string Encode(const Model& model, const std::vector& textures, const std::vector& darkTextures = {}); /** * A texture stored inside a .nif (NiPixelData or NiPersistentSrcTextureRendererData, block `block`) as a DDS file diff --git a/dDashboardServer/routes/Scenery.cpp b/dDashboardServer/routes/Scenery.cpp index 6ae1ad395..794ae215f 100644 --- a/dDashboardServer/routes/Scenery.cpp +++ b/dDashboardServer/routes/Scenery.cpp @@ -245,8 +245,9 @@ namespace { * 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. + * 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 nlohmann::json LightingJson(const WorldScene::Lighting& lighting) { @@ -591,12 +592,17 @@ namespace { return nullptr; } const auto folder = FolderOf(path); - std::vector textures; // per mesh: a res path, "#" for one stored in the .nif, or empty + // Per mesh: a res path, "#" 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 textures, darkTextures; for (const auto& mesh : model->meshes) { - if (mesh.material.embeddedTexture >= 0) textures.push_back("#" + std::to_string(mesh.material.embeddedTexture)); - else textures.push_back(mesh.material.texture.empty() ? std::string{} : FindTexture(folder, mesh.material.texture)); + textures.push_back(where(mesh.material.embeddedTexture, mesh.material.texture)); + darkTextures.push_back(where(mesh.material.embeddedDarkTexture, mesh.material.darkTexture)); } - auto encoded = std::make_shared(NifFile::Encode(*model, textures)); + auto encoded = std::make_shared(NifFile::Encode(*model, textures, darkTextures)); g_Disk.Store(target, *encoded); return encoded; } diff --git a/dDashboardServer/static/js/lddviewer.js b/dDashboardServer/static/js/lddviewer.js index 2d0cc8900..5408eab52 100644 --- a/dDashboardServer/static/js/lddviewer.js +++ b/dDashboardServer/static/js/lddviewer.js @@ -14,7 +14,7 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.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 MAX_PARALLEL_FETCHES = 6; @@ -499,6 +499,7 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl scene.add(sun, sun.target); 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); 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); controls.target.copy(center); 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.updateProjectionMatrix(); } @@ -750,6 +751,13 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl if (onTick) onTick(dt); updateBubbles(now); 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); })(); diff --git a/dDashboardServer/static/js/scenery-core.js b/dDashboardServer/static/js/scenery-core.js index 993c45f5f..5582c1b15 100644 --- a/dDashboardServer/static/js/scenery-core.js +++ b/dDashboardServer/static/js/scenery-core.js @@ -26,6 +26,10 @@ export function parseModel(buffer) { mesh.uvs = new Float32Array(buffer, offset, n * 2); offset += n * 8; } + if (entry.uv2) { + mesh.uvs2 = new Float32Array(buffer, offset, n * 2); + offset += n * 8; + } if (entry.colors) { mesh.colors = new Uint8Array(buffer, offset, n * 4); offset += n * 4; @@ -63,12 +67,13 @@ export function shaderOf(manifest, asset, mesh) { } // 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, - * vertexColors, material} — whether the scene's sun and ambient light it, its texture and vertex colors are used, and - * whether its NiMaterialProperty colors are (only fixed function and the "Material" shaders use them). Null without + * vertexColors, material, layers} — whether the scene's sun and ambient light it, its texture and vertex colors are + * 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. */ export function gameLook(manifest, asset, mesh) { @@ -81,7 +86,8 @@ export function gameLook(manifest, asset, mesh) { texture: !(bits & SHADER_LOOK.NO_TEXTURE), // 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)), - 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) { 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.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, []); groups.get(key).push(mesh); } @@ -107,6 +113,7 @@ export function mergeMeshes(meshes) { const out = { ...first, vertices, positions: new Float32Array(vertices * 3) }; if (first.normals) out.normals = new Int8Array(vertices * 3); 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); out.indices = vertices > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount); let v = 0, i = 0; @@ -114,6 +121,7 @@ export function mergeMeshes(meshes) { out.positions.set(m.positions, v * 3); if (out.normals) out.normals.set(m.normals, v * 3); 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); for (let k = 0; k < m.indices.length; k++) out.indices[i + k] = m.indices[k] + v; v += m.vertices; @@ -382,3 +390,13 @@ export function loadedScenes(scenes, scene) { if (entry) for (const n of entry.neighbours) loaded.add(n); 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)); +} diff --git a/dDashboardServer/static/js/scenery.js b/dDashboardServer/static/js/scenery.js index fa35572df..fb81d93dd 100644 --- a/dDashboardServer/static/js/scenery.js +++ b/dDashboardServer/static/js/scenery.js @@ -165,6 +165,23 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress #ifdef USE_MAP diffuseColor.rgb *= texture2D( map, vMapUv ).rgb; #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 @@ -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 * shader reads it. */ - function gameMaterial(options, mesh, alphaMode, look) { + function gameMaterial(options, mesh, alphaMode, look, darkMap = null) { const material = new THREE.MeshBasicMaterial({ ...options, color: look.material ? options.color : new THREE.Color(1, 1, 1) }); 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) => { - 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 ', '#include \n\tvUvDark = uvDark;'); + shader.fragmentShader = 'uniform sampler2D darkMap;\nuniform vec2 layerWeights;\nvarying vec2 vUvDark;\n' + shader.fragmentShader + .replace('#include ', layers === 'blended' ? TWO_LAYERS_BLENDED_FRAGMENT : TWO_LAYERS_ADDED_FRAGMENT) + .replace('#include ', layers === 'blended' ? '#ifdef USE_COLOR_ALPHA\n\tdiffuseColor.rgb *= vColor.rgb;\n#endif' : '#include '); + } else if (options.map) { + textureAlphaPatch(shader, alphaMode); + } if (!look.lit) return; Object.assign(shader.uniforms, gameLights); 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.replace('#include ', '#include \n\treflectedLight.indirectDiffuse *= vGameLight;'); }; - material.customProgramCacheKey = () => 'game:' + (options.map ? alphaMode : '') + ':' + look.lit; + material.customProgramCacheKey = () => 'game:' + (options.map ? alphaMode : '') + ':' + look.lit + ':' + layers; 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- // 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. const vertexColors = usesVertexColors(mesh, look); 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); // 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; @@ -275,7 +307,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress map: map || null }; 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 }); material.emissive.setRGB(mesh.emissive[0], mesh.emissive[1], mesh.emissive[2], THREE.SRGBColorSpace); return useTextureAlpha(material, alphaMode); @@ -290,18 +322,21 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress if (!mesh.vertices || !mesh.indices.length) continue; // The sky keeps its own unlit look const look = forSky ? null : gameLook(manifest, asset, mesh); - let map = null; - if (mesh.texture >= 0 && mesh.uv && (!look || look.texture)) { - const texture = await loadTexture(asset, mesh.texture, model.header.textures[mesh.texture], lod); - if (texture) { - map = texture.clone(); // shares the image; wrapping differs per mesh - map.wrapS = mesh.clampU ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping; - map.wrapT = mesh.clampV ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping; - map.needsUpdate = true; - } - } + const textureOf = async (slot, clampU, clampV) => { + const texture = await loadTexture(asset, slot, model.header.textures[slot], lod); + if (!texture) return null; + const map = texture.clone(); // shares the image; wrapping differs per mesh + map.wrapS = clampU ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping; + map.wrapT = clampV ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping; + 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); - 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 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) { 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); + mesh.userData.asset = asset; // which of the manifest's models it is, for picking cellInstances.forEach((instance, i) => { position.set(instance.x, instance.y, instance.z); rotation.set(instance.qx, instance.qy, instance.qz, instance.qw); diff --git a/dDashboardServer/static/js/world3d.js b/dDashboardServer/static/js/world3d.js index 81dc75c28..de785785c 100644 --- a/dDashboardServer/static/js/world3d.js +++ b/dDashboardServer/static/js/world3d.js @@ -19,6 +19,7 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; import { buildTerrainChunks, TERRAIN_LOOKS } from '/js/lddviewer.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'; const LIVE_DELAY = 1.2; // seconds live players are drawn behind the newest report @@ -76,6 +77,14 @@ const scenery = createScenery({ 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) ---- function renderScenes(info) { @@ -1112,6 +1121,7 @@ function animate() { } } controls.update(); + fitNearPlane(camera, controls.target); scenery.update(dt); flairs.update(dt); renderer.render(scene, camera); diff --git a/tests/dWebTests/NifFileTests.cpp b/tests/dWebTests/NifFileTests.cpp index 62a27d08c..300e2550d 100644 --- a/tests/dWebTests/NifFileTests.cpp +++ b/tests/dWebTests/NifFileTests.cpp @@ -213,6 +213,56 @@ TEST(NifFileTests, PassesPropertiesDownTheTree) { 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(1).Put(name).Put(-1).Floats({ 0, 0, 0 }).Put(1).Put(1).Put(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(0).Put(9).Put(1).Put(base).Put(0x3201).Put(1); + texturing.Raw(std::string(32, '\0')); + texturing.Put(1).Put(dark).Put(0x3200).Put(0); + for (int slot = 2; slot < 9; slot++) texturing.Put(0); + texturing.Put(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(0).Put(3).Put(0).Put(0).Put(1); + for (int i = 0; i < 3; i++) data.Floats({ static_cast(i == 1), static_cast(i == 2), 0.0f }); + data.Put(2).Put(0).Floats({ 0, 0, 0, 1 }).Put(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(0).Put(-1).Put(1).Put(3).Put(1).Put(0).Put(1).Put(2).Put(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) { NifBuilder nif; const auto root = nif.Add("NiNode", {}); @@ -434,6 +484,8 @@ TEST(WorldSceneTests, LightsAZoneAsMostOfItsObjectsAre) { TEST(NifFileTests, KnowsWhatEachShaderLeavesOut) { 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(-1), 0); // fixed function is lit by Gamebryo EXPECT_EQ(NifFile::ShaderLookFor(33), NifFile::UNLIT | NifFile::NO_TEXTURE); // Basic NL VC NT diff --git a/tests/dWebTests/scenery-core.test.mjs b/tests/dWebTests/scenery-core.test.mjs index 3d1be5160..32ca75fc3 100644 --- a/tests/dWebTests/scenery-core.test.mjs +++ b/tests/dWebTests/scenery-core.test.mjs @@ -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'); // 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 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, 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 -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 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 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) { console.error(`${failures} failed`); process.exit(1);