diff --git a/dCommon/NifFile.cpp b/dCommon/NifFile.cpp index c737b0dd2..18baa4e5b 100644 --- a/dCommon/NifFile.cpp +++ b/dCommon/NifFile.cpp @@ -661,6 +661,39 @@ 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 + switch (shader) { + // Basic NL Material, Over Everything Material Unlit + case 32: case 108: + return UNLIT | NO_VERTEX_COLORS | MATERIAL_COLOR; + // 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; + // 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: + return UNLIT | NO_TEXTURE; + // Basic VC NT, Opaque VC NT NoFog + case 37: case 85: + return NO_TEXTURE; + // 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 + 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: + return UNLIT; + default: + return 0; + } + } + std::optional Parse(std::string_view data, uint32_t lod, std::string& error) { return Parser(data, lod).Run(error); } diff --git a/dCommon/NifFile.h b/dCommon/NifFile.h index 582f00099..a0996dd32 100644 --- a/dCommon/NifFile.h +++ b/dCommon/NifFile.h @@ -72,6 +72,22 @@ namespace NifFile { // What a texture's alpha does under a shader (mapShaders.gameValue); -1 is fixed function (opacity) eTextureAlpha TextureAlphaFor(int32_t shader); + /** + * What a shader (mapShaders.gameValue) leaves out of the usual lit look, which is (sun * max(0, N.L) + ambient) + * * vertex color * texture, the scene's lights from its .lvl, NiMaterialProperty's colors unused (the client's + * BasicShaders.fx, LEGOPPLighting.fx and Ocean.fx). Fixed function (-1) has none of these: Gamebryo lights it with + * the material and NiVertexColorProperty. + */ + enum eShaderLook : uint8_t { + 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) + }; + + // eShaderLook bits of a shader (mapShaders.gameValue); 0 for the usual lit look and for fixed function + uint8_t ShaderLookFor(int32_t shader); + // Where a node is in the model's space: p' = rotation * p + translation (row-major, scale folded in) struct NodeTransform { std::array rotation{ 1, 0, 0, 0, 1, 0, 0, 0, 1 }; diff --git a/dDashboardServer/routes/Scenery.cpp b/dDashboardServer/routes/Scenery.cpp index f49894905..d3e6828e5 100644 --- a/dDashboardServer/routes/Scenery.cpp +++ b/dDashboardServer/routes/Scenery.cpp @@ -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(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& 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 assets; // res paths of models, indexed by the manifests std::map 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 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 assetShaders; int64_t sky = -1; + std::vector> 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(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"; diff --git a/dDashboardServer/routes/WorldScene.h b/dDashboardServer/routes/WorldScene.h index 4cfa4bdc0..90af84707 100644 --- a/dDashboardServer/routes/WorldScene.h +++ b/dDashboardServer/routes/WorldScene.h @@ -2,9 +2,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -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 ambient{}; + std::array specular{}; + std::array upperHemi{}; + std::array light{}; // the sun's color + std::array lightVec{}; // toward the sun, unit length + std::array 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 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& 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 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(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 ZoneLighting(const std::vector>& scenes) { + std::vector> 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). diff --git a/dDashboardServer/static/js/scenery-core.js b/dDashboardServer/static/js/scenery-core.js index af49c7a1b..f68ba6fbc 100644 --- a/dDashboardServer/static/js/scenery-core.js +++ b/dDashboardServer/static/js/scenery-core.js @@ -43,14 +43,46 @@ export function parseModel(buffer) { * their shader in their node names (mesh.shaderTag); a tag the client can't use falls back to the LEGO shader. */ export function textureAlphaMode(manifest, asset, mesh) { - if (!manifest || !manifest.shaders || !manifest.textureAlpha) return 'opacity'; - let shader = manifest.shaders[asset]; - if (shader === undefined || shader === null) return 'opacity'; - if (shader === manifest.multishader) { - const tagged = mesh && mesh.shaderTag >= 0 && manifest.shaderTags ? manifest.shaderTags[mesh.shaderTag] : undefined; - shader = tagged !== undefined && tagged >= 3 && tagged <= 108 ? tagged : manifest.defaultShader; - } - return manifest.textureAlpha[shader] || 'opacity'; + if (!manifest || !manifest.textureAlpha) return 'opacity'; + const shader = shaderOf(manifest, asset, mesh); + return shader === null ? 'opacity' : manifest.textureAlpha[shader] || 'opacity'; +} + +/** + * The shader (mapShaders.gameValue) the game draws a mesh of a model with, or null when the manifest doesn't say + * (-1 is fixed function). A multishader model's parts name theirs in their node names (mesh.shaderTag, a mapShaders + * id); a tag the client can't use falls back to the LEGO shader. + */ +export function shaderOf(manifest, asset, mesh) { + if (!manifest || !manifest.shaders) return null; + const shader = manifest.shaders[asset]; + if (shader === undefined || shader === null) return null; + if (shader !== manifest.multishader) return shader; + const tagged = mesh && mesh.shaderTag >= 0 && manifest.shaderTags ? manifest.shaderTags[mesh.shaderTag] : undefined; + return tagged !== undefined && tagged >= 3 && tagged <= 108 ? tagged : manifest.defaultShader; +} + +// NifFile::eShaderLook bits +export const SHADER_LOOK = { UNLIT: 1, NO_TEXTURE: 2, NO_VERTEX_COLORS: 4, MATERIAL_COLOR: 8 }; + +/** + * 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 + * lighting in the manifest (older servers), for the viewer's own lights. + */ +export function gameLook(manifest, asset, mesh) { + if (!manifest || !manifest.lighting) return null; + const shader = shaderOf(manifest, asset, mesh); + const fixedFunction = shader === null || shader < 0; + const bits = fixedFunction || !manifest.shaderLooks ? 0 : manifest.shaderLooks[shader] || 0; + return { + lit: !(bits & SHADER_LOOK.UNLIT), + 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) + }; } /** diff --git a/dDashboardServer/static/js/scenery.js b/dDashboardServer/static/js/scenery.js index 46286c935..29768faae 100644 --- a/dDashboardServer/static/js/scenery.js +++ b/dDashboardServer/static/js/scenery.js @@ -9,7 +9,7 @@ * follows the camera, far away, behind everything. */ import * as THREE from 'three'; -import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf, textureAlphaMode } from '/js/scenery-core.js'; +import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf, textureAlphaMode, gameLook } from '/js/scenery-core.js'; // Per detail level (the property view's 0 high, 1 medium, 2 low): the model LOD, how far objects are drawn, the // largest texture side and a memory budget for geometry and textures @@ -64,6 +64,12 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress onProgress(done, wanted); } + // A model or texture URL with the manifest's conversion format, so a browser's week-long cache of models + // converted the old way isn't used once the server converts them anew + function versioned(url) { + return manifest && manifest.format ? url + (url.includes('?') ? '&' : '?') + 'v=' + manifest.format : url; + } + async function fetchBuffer(url) { const response = await fetch(url, { credentials: 'same-origin' }); if (!response.ok) throw new Error(response.status + ' ' + url); @@ -105,7 +111,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress const key = lod + '/' + (path.startsWith('#') ? asset + path : path); if (!textures.has(key)) { const size = detail.texture; - textures.set(key, fetchBuffer(urls.texture(manifest.zone, asset, slot, lod)).then((buffer) => { + textures.set(key, fetchBuffer(versioned(urls.texture(manifest.zone, asset, slot, lod))).then((buffer) => { const texture = makeTexture(buffer, size); if (texture) used += texture.userData.bytes; return texture; @@ -114,12 +120,15 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress return textures.get(key); } - function geometryOf(mesh) { + // Whether a mesh's vertex colors are drawn: as its game look says, else as NiVertexColorProperty does + const usesVertexColors = (mesh, look) => (look ? look.vertexColors : !!(mesh.colors && mesh.vertexColors !== 0)); + + function geometryOf(mesh, look) { const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3)); if (mesh.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3, true)); if (mesh.uvs) geometry.setAttribute('uv', new THREE.BufferAttribute(mesh.uvs, 2)); - if (mesh.colors && mesh.vertexColors !== 0) geometry.setAttribute('color', new THREE.BufferAttribute(linearColors(mesh.colors), 4, true)); + if (usesVertexColors(mesh, look)) geometry.setAttribute('color', new THREE.BufferAttribute(linearColors(mesh.colors), 4, true)); geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1)); if (!mesh.normals) geometry.computeVertexNormals(); geometry.computeBoundingSphere(); @@ -149,51 +158,105 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress #endif `; + // The texture alpha mode's change to a fragment shader + function textureAlphaPatch(shader, mode) { + if (mode === 'decal') { + shader.fragmentShader = shader.fragmentShader.replace('#include ', '').replace('#include ', DECAL_FRAGMENT); + } else if (mode === 'ignored') { + shader.fragmentShader = shader.fragmentShader.replace('#include ', OPAQUE_MAP_FRAGMENT); + } + } + function useTextureAlpha(material, mode) { if (mode === 'opacity' || !material.map) return material; - material.onBeforeCompile = (shader) => { - if (mode === 'decal') { - shader.fragmentShader = shader.fragmentShader.replace('#include ', '').replace('#include ', DECAL_FRAGMENT); - } else { - shader.fragmentShader = shader.fragmentShader.replace('#include ', OPAQUE_MAP_FRAGMENT); - } - }; + material.onBeforeCompile = (shader) => textureAlphaPatch(shader, mode); material.customProgramCacheKey = () => 'textureAlpha:' + mode; return material; } - function materialOf(mesh, map, forSky, alphaMode = 'opacity') { + // The zone's lights as the game's shaders get them (manifest.lighting), shared by every lit material + const gameLights = { gameLightColor: { value: new THREE.Vector3(1, 1, 1) }, gameAmbient: { value: new THREE.Vector3() }, gameLightVec: { value: new THREE.Vector3(0, 1, 0) } }; + function setGameLights(lighting) { + if (!lighting) return; + gameLights.gameLightColor.value.fromArray(lighting.light); + gameLights.gameAmbient.value.fromArray(lighting.ambient); + gameLights.gameLightVec.value.fromArray(lighting.lightVec); + } + + /** + * Lighting per vertex as BasicShaders.fx and LEGOPPLighting.fx do it: sun * max(0, N.L) + ambient in the game's + * (sRGB) color space, which the vertex shader's color output clamps to 1, then made linear for three.js. + */ + const GAME_LIGHT_VERTEX = `#include + vec3 gameNormal = normal; + #ifdef USE_INSTANCING + gameNormal = mat3( instanceMatrix ) * gameNormal; + #endif + gameNormal = normalize( mat3( modelMatrix ) * gameNormal ); + vGameLight = pow( clamp( gameLightColor * max( 0.0, dot( gameNormal, gameLightVec ) ) + gameAmbient, 0.0, 1.0 ), vec3( 2.2 ) );`; + + /** + * A material that draws a mesh the way its game shader does (gameLook): unlit by the view's own lights and tone + * 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) { + const material = new THREE.MeshBasicMaterial({ + ...options, + color: look.material ? options.color : new THREE.Color(1, 1, 1) + }); + material.toneMapped = false; + material.onBeforeCompile = (shader) => { + 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' + + shader.vertexShader.replace('#include ', GAME_LIGHT_VERTEX); + shader.fragmentShader = 'varying vec3 vGameLight;\n' + + shader.fragmentShader.replace('#include ', '#include \n\treflectedLight.indirectDiffuse *= vGameLight;'); + }; + material.customProgramCacheKey = () => 'game:' + (options.map ? alphaMode : '') + ':' + look.lit; + return material; + } + + function materialOf(mesh, map, forSky, alphaMode = 'opacity', look = 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 (mesh.colors && mesh.vertexColors !== 0) for (let i = 3; i < mesh.colors.length && !vertexAlpha; i += 4) vertexAlpha = mesh.colors[i] < 250; + if (vertexColors) 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 seeThrough = mesh.blend && (mesh.alpha < 0.99 || vertexAlpha || textureAlpha); + // 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 seeThrough = mesh.blend && (materialAlpha < 0.99 || vertexAlpha || textureAlpha); const options = { color: new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace), - vertexColors: !!(mesh.colors && mesh.vertexColors !== 0), + vertexColors, transparent: seeThrough, - opacity: mesh.alpha, + opacity: materialAlpha, alphaTest: mesh.test >= 0 ? Math.max(mesh.test / 255, 0.01) : 0, side: mesh.doubleSided ? THREE.DoubleSide : THREE.FrontSide, map: map || null }; if (forSky) return useTextureAlpha(new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false }), alphaMode); + if (look) return gameMaterial(options, mesh, alphaMode, look); 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); } async function buildParts(asset, lod, forSky) { - const buffer = await fetchBuffer(urls.mesh(manifest.zone, asset, lod)); + const buffer = await fetchBuffer(versioned(urls.mesh(manifest.zone, asset, lod))); const model = parseModel(buffer); const parts = []; // The sky's layers keep their order; everything else has its look-alike pieces joined for (const mesh of forSky ? model.meshes : mergeMeshes(model.meshes)) { 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) { + 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 @@ -202,8 +265,8 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress map.needsUpdate = true; } } - const geometry = geometryOf(mesh); - parts.push({ geometry, material: materialOf(mesh, map, forSky, textureAlphaMode(manifest, asset, mesh)) }); + const geometry = geometryOf(mesh, look); + parts.push({ geometry, material: materialOf(mesh, map, forSky, textureAlphaMode(manifest, asset, mesh), look) }); } 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; @@ -361,6 +424,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress if (!response.ok) { manifest = null; return false; } manifest = await response.json(); manifest.url = url; + setGameLights(manifest.lighting); byAsset = groupObjects(manifest.objects); if (manifest.sky >= 0) byAsset.delete(manifest.sky); } diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 55287f9e2..0dab41635 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -82,3 +82,10 @@ target_link_libraries(dWebTests ${COMMON_LIBRARIES} dWeb dUgc sqlite3 OpenSSL::C gtest_discover_tests(dWebTests) target_compile_definitions(dWebTests PRIVATE DLU_SOURCE_DIR="${PROJECT_SOURCE_DIR}") + +# The 3D views' shader lookups (static/js/scenery-core.js), when node is there +find_program(NODE_EXECUTABLE node) +if(NODE_EXECUTABLE) + add_test(NAME SceneryCoreJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/scenery-core.test.mjs" + "${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/scenery-core.js") +endif() diff --git a/tests/dWebTests/NifFileTests.cpp b/tests/dWebTests/NifFileTests.cpp index 4bb940eb6..62a27d08c 100644 --- a/tests/dWebTests/NifFileTests.cpp +++ b/tests/dWebTests/NifFileTests.cpp @@ -355,6 +355,93 @@ TEST(WorldSceneTests, ReadsTheSkydomeOfASceneFile) { EXPECT_EQ(WorldScene::ReadSkydome(""), ""); } +namespace { + // A scene file (`version`) with one environment chunk whose lighting is `lighting` + std::string SceneWithLighting(uint32_t version, const Bytes& lighting) { + Bytes lvl; + // File info chunk (data at 0x20: version), then the environment chunk (header at 0x34, data at 0x54) whose + // lighting starts at 0x60 + lvl.Raw("CHNK").Put(1000).Put(1).Put(1).Put(0x34).Put(0x20); + lvl.Raw(std::string(0x20 - lvl.data.size(), '\xCD')); + lvl.Put(version).Put(0).Put(0x34).Put(0).Put(0); + lvl.Raw("CHNK").Put(2000).Put(1).Put(2).Put(0x60 + static_cast(lighting.data.size()) - 0x34).Put(0x54); + lvl.Raw(std::string(0x54 - lvl.data.size(), '\xCD')); + lvl.Put(0x60).Put(0).Put(0); + lvl.Raw(lighting.data); + return lvl.data; + } + + Bytes& Floats(Bytes& bytes, std::initializer_list values) { + for (const auto value : values) bytes.Put(value); + return bytes; + } +} + +// As the client's level_read_lighting_info: version 48 has a blend time, two draw distance settings and cull groups +TEST(WorldSceneTests, ReadsTheLightingOfASceneFile) { + Bytes lighting; + Floats(lighting, { 10.0f }); // blend time + Floats(lighting, { 0.42f, 0.62f, 0.75f }); // ambient + Floats(lighting, { 1, 1, 1 }); // specular + Floats(lighting, { 1, 0.7f, 0.5f }); // upper hemisphere + Floats(lighting, { 0, -3, -4 }); // the way the sun shines + Floats(lighting, { 100, 300, 50, 50, 8000, 8000 }); // lowest draw distances + Floats(lighting, { 250, 350, 100, 100, 8000, 8000 }); // highest + lighting.Put(2).Put(7); + Floats(lighting, { 1, 2 }); + lighting.Put(8); + Floats(lighting, { 3, 4 }); // cull groups + Floats(lighting, { 0.5f, 0.8f, 0.9f }); // fog color + Floats(lighting, { 1, 0.9f, 0.8f }); // sun color + const auto read = WorldScene::ReadLighting(SceneWithLighting(48, lighting)); + ASSERT_TRUE(read); + EXPECT_FLOAT_EQ(read->ambient[1], 0.62f); + EXPECT_FLOAT_EQ(read->upperHemi[2], 0.5f); + EXPECT_FLOAT_EQ(read->lightVec[0], 0.0f); // toward the sun: the stored direction turned around, unit length + EXPECT_FLOAT_EQ(read->lightVec[1], 0.6f); + EXPECT_FLOAT_EQ(read->lightVec[2], 0.8f); + EXPECT_FLOAT_EQ(read->fogNear, 250.0f); + EXPECT_FLOAT_EQ(read->fogFar, 350.0f); + EXPECT_FLOAT_EQ(read->fogColor[2], 0.9f); + EXPECT_FLOAT_EQ(read->light[1], 0.9f); + + // Version 35: no blend time, one fog range, no sun color + Bytes old; + Floats(old, { 0.5f, 0.5f, 0.5f, 1, 1, 1, 1, 1, 1, 0, -1, 0, 20, 90, 0.1f, 0.2f, 0.3f }); + const auto older = WorldScene::ReadLighting(SceneWithLighting(35, old)); + ASSERT_TRUE(older); + EXPECT_FLOAT_EQ(older->ambient[0], 0.5f); + EXPECT_FLOAT_EQ(older->lightVec[1], 1.0f); + EXPECT_FLOAT_EQ(older->fogFar, 90.0f); + EXPECT_FLOAT_EQ(older->fogColor[1], 0.2f); + EXPECT_FLOAT_EQ(older->light[0], 0.0f); + + // Cut short, or no environment chunk + const auto whole = SceneWithLighting(48, lighting); + EXPECT_FALSE(WorldScene::ReadLighting(whole.substr(0, whole.size() - 8))); + EXPECT_FALSE(WorldScene::ReadLighting("")); +} + +TEST(WorldSceneTests, LightsAZoneAsMostOfItsObjectsAre) { + WorldScene::Lighting day, dusk; + day.ambient = { 1, 1, 1 }; + dusk.ambient = { 0.2f, 0.2f, 0.4f }; + EXPECT_FALSE(WorldScene::ZoneLighting({})); + // Two scenes lit like dusk hold more objects than the day one + EXPECT_EQ(WorldScene::ZoneLighting({ { day, 50 }, { dusk, 30 }, { dusk, 25 } }), dusk); + EXPECT_EQ(WorldScene::ZoneLighting({ { day, 10 }, { dusk, 10 } }), day); // a tie goes to the first +} + +TEST(NifFileTests, KnowsWhatEachShaderLeavesOut) { + EXPECT_EQ(NifFile::ShaderLookFor(38), 0); // Basic VC: lit, textured, vertex colors + 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 + EXPECT_EQ(NifFile::ShaderLookFor(37), NifFile::NO_TEXTURE); // Basic VC NT + EXPECT_EQ(NifFile::ShaderLookFor(70), NifFile::UNLIT); // ScrollingUV_NoLight_AnimAlpha + EXPECT_EQ(NifFile::ShaderLookFor(32), NifFile::UNLIT | NifFile::NO_VERTEX_COLORS | NifFile::MATERIAL_COLOR); // Basic NL Material +} + // The game client's own meshes, when a client is configured (DLU_CLIENT_RES, else client_location in the build's // sharedconfig.ini): the first 300 .nif files under res/mesh/env read, and most have something to draw TEST(NifFileTests, ReadsTheClientsMeshes) { diff --git a/tests/dWebTests/scenery-core.test.mjs b/tests/dWebTests/scenery-core.test.mjs new file mode 100644 index 000000000..6be904231 --- /dev/null +++ b/tests/dWebTests/scenery-core.test.mjs @@ -0,0 +1,50 @@ +// The 3D views' shader lookups (static/js/scenery-core.js): which shader draws a mesh and what it uses. +// Run by ctest: node scenery-core.test.mjs +import { pathToFileURL } from 'node:url'; + +const [modulePath] = process.argv.slice(2); +const S = await import(pathToFileURL(modulePath).href); +let failures = 0; +const same = (actual, expected, what) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures++; + console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`); + } +}; + +const manifest = { + shaders: [38, 9999, -1, 33], + shaderTags: { 1: 5, 30: 38, 25: 33, 2: 2 }, + textureAlpha: { 5: 'decal' }, + shaderLooks: { 33: S.SHADER_LOOK.UNLIT | S.SHADER_LOOK.NO_TEXTURE }, + multishader: 9999, + defaultShader: 5, + lighting: { ambient: [0.4, 0.6, 0.7], light: [1, 1, 1], lightVec: [0, 1, 0] } +}; +const colored = { colors: new Uint8Array(4), vertexColors: 2 }; + +// A multishader part's tag names its shader; an unusable or missing tag is the LEGO shader +same(S.shaderOf(manifest, 1, { shaderTag: 30 }), 38, 'tagged part'); +same(S.shaderOf(manifest, 1, { shaderTag: 2 }), 5, 'unusable tag'); +same(S.shaderOf(manifest, 1, { shaderTag: -1 }), 5, 'untagged part'); +same(S.shaderOf(manifest, 0, {}), 38, 'object shader'); +same(S.shaderOf({}, 0, {}), null, 'no shaders in the manifest'); +same(S.textureAlphaMode(manifest, 1, { shaderTag: 1 }), 'decal', 'LEGO 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 +same(S.gameLook(manifest, 1, { ...colored, shaderTag: 30 }), { lit: true, texture: true, vertexColors: true, material: false }, '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'); +// 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'); +// Without the zone's lighting the viewer lights scenery itself +same(S.gameLook({ ...manifest, lighting: null }, 0, colored), null, 'no lighting'); + +if (failures) { + console.error(`${failures} failed`); + process.exit(1); +} +console.log('scenery-core: all passed');