diff --git a/dCommon/NifFile.cpp b/dCommon/NifFile.cpp index dace685af..131408d85 100644 --- a/dCommon/NifFile.cpp +++ b/dCommon/NifFile.cpp @@ -719,79 +719,182 @@ namespace NifFile { return tagShader && *tagShader >= 3 && *tagShader <= 0x6C ? *tagShader : LEGO_SHADER; } - eTextureAlpha TextureAlphaFor(int32_t shader) { - switch (shader) { + namespace { + using F = eShaderFamily; + constexpr auto OPACITY = eTextureAlpha::OPACITY, DECAL = eTextureAlpha::DECAL, IGNORED = eTextureAlpha::IGNORED; + + struct TechniqueRow { + int32_t shader; + ShaderTechnique technique; + }; + + /** + * Every mapShaders gameValue, by the technique its shader class sets up (ShaderManager's factory table at + * 0x01889608, indexed by gameValue; the class's technique setup names it) and the client's res/shaders/*.fx. + * 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 (94 "Basic": the vtable slot at +0x90 of the class made at 0x0045f240 names + * it), 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, "AnimUV"/"ScrollingUV" the texture transform, "OneSidedAlpha" + * AlphaAsAlpha culled) and the technique of that name in res/shaders. + */ + constexpr TechniqueRow TECHNIQUES[] = { + { -1, { F::FIXED_FUNCTION, 0, OPACITY, 0 } }, + // TerrainDiffuse.fx's mesh techniques: the texture times the vertex colors and the light, alpha only the fade + { 2, { F::TERRAIN, 0, OPACITY, NO_BLEND } }, // Terrain Mesh + { 3, { F::TERRAIN, 0, IGNORED, RIM_LIGHT | NO_BLEND } }, // Terrain Mesh Rim Light + { 97, { F::TERRAIN, 0, OPACITY, DIFFUSE_ONLY | NO_BLEND } }, // Terrain Diffuse Map Only // LEGOPPLighting: textured alone the texture's alpha is forced to 1; with vertex colors it only lays the // texture over them (lerp by its alpha) and the vertex alpha is what shows through - case 4: case 5: case 12: case 25: case 27: case 28: case 29: case 30: case 50: case 72: case 88: - // Darkling: the same lay-over; alpha from lighting or the fade - case 75: case 76: case 77: case 102: case 103: case 104: - return eTextureAlpha::DECAL; + { 4, { F::LEGO, 0, DECAL, 0 } }, // LEGO (No LOD) + { 5, { F::LEGO, 0, DECAL, 0 } }, // LEGO + { 12, { F::LEGO, 0, DECAL, 0 } }, // LEGO-Reveal (the reveal mask left out) + { 14, { F::LEGO, 0, OPACITY, NON_DECAL } }, // LEGO Masked NonDecal (the specular mask left out) + { 19, { F::LEGO, 0, DECAL, 0 } }, // Powerups (their own effect, drawn as LEGO) + { 20, { F::LEGO, 0, DECAL, 0 } }, // Orb (Powerups.fx, drawn as LEGO) + { 22, { F::LEGO, EMISSIVE, OPACITY, SUPER_EMISSIVE } }, // LEGO-SuperEmissive + { 25, { F::LEGO, 0, DECAL, 0 } }, // LEGO_FrontEnd + { 26, { F::LEGO, 0, OPACITY, NON_DECAL } }, // LEGO_FaceCreate + { 27, { F::LEGO, 0, DECAL, GLOW } }, // LEGO-Glow + { 28, { F::LEGO, 0, DECAL, GRAYSCALE } }, // LEGO-Grayscale + { 29, { F::LEGO, 0, DECAL, GLOW | IGNORE_VERTEX_ALPHA } }, // LEGO-Glow-IgnoreVertAlpha + { 30, { F::LEGO, 0, DECAL, UV_ANIM } }, // LEGO-AnimUV // LEGOPPLighting_Item: texture alpha forced to 1, multiplied by the vertex colors - case 31: case 48: - // TerrainMeshLighting_Rim: texture times vertex colors, alpha only the fade - case 3: - return eTextureAlpha::IGNORED; - default: - return eTextureAlpha::OPACITY; + { 31, { F::LEGO, 0, IGNORED, NON_DECAL } }, // LEGO-Item + { 48, { F::LEGO, 0, IGNORED, NON_DECAL | GLOW } }, // LEGO-ItemGlow + { 50, { F::LEGO, 0, DECAL, 0 } }, // LEGO-FadeUp (as when faded in) + { 53, { F::LEGO, EMISSIVE, OPACITY, 0 } }, // LEGO-Emissive + { 72, { F::LEGO, 0, DECAL, SHINY_GLINT } }, // ShinyGlint + { 88, { F::LEGO, 0, DECAL, NO_AMBIENT } }, // LEGO NoAmbient + { 92, { F::LEGO, 0, DECAL, 0 } }, // Pet Taming LEGO In Cloud + // LEGOPPLighting's NL pixel shaders: the vertex color or texture as it is + { 52, { F::BASIC, UNLIT, OPACITY, ANIM_ALPHA } }, // LEGO-No Light + // Darkling: the same lay-over; the dark texture on the second UV set through a window of vertex alphas + { 75, { F::DARKLING, 0, DECAL, 0 } }, // Darkling + { 76, { F::DARKLING, 0, DECAL, SPECULAR } }, // Darkling /w Specular + { 77, { F::DARKLING, 0, DECAL, NON_DECAL } }, // Darkling Structure + { 102, { F::DARKLING, 0, DECAL, SHINY_GLINT } }, // Darking Shiny Glint + { 103, { F::DARKLING, 0, DECAL, SPECULAR | SHINY_GLINT } }, // Darkling /w Specular Shiny Glint + { 104, { F::DARKLING, 0, DECAL, NON_DECAL | SHINY_GLINT } }, // Darkling Structure Shiny Glint + // AlphaAsAlpha: texture times the (lit) vertex color, both sides + { 7, { F::BASIC, 0, OPACITY, DOUBLE_SIDED } }, // VertColor_Alpha + { 8, { F::BASIC, UNLIT, OPACITY, DOUBLE_SIDED | ANIM_ALPHA } }, // VertColor_NoLighting_Alpha + { 9, { F::BASIC, 0, OPACITY, DOUBLE_SIDED } }, // VertColor_Alpha_Fade + { 10, { F::BASIC, UNLIT, OPACITY, DOUBLE_SIDED | ANIM_ALPHA | BLEND } }, // VertColorTex_NoLight_AlphaBlend + { 54, { F::BASIC, UNLIT, OPACITY, DOUBLE_SIDED | ANIM_ALPHA | ALPHA_TEST } }, // VertColorTex_NoLight_AlphaTest + { 13, { F::BASIC, 0, OPACITY, UV_ANIM } }, // ScrollingUV + { 70, { F::BASIC, UNLIT, OPACITY, UV_ANIM | ANIM_ALPHA } }, // ScrollingUV_NoLight_AnimAlpha + { 73, { F::BASIC, UNLIT, OPACITY, UV_ANIM | ANIM_ALPHA } }, // ScrollingUV_NoLight_AimAlpha_Post + { 81, { F::BASIC, UNLIT, OPACITY, UV_ANIM | ANIM_ALPHA | NO_FOG } }, // ScrollingUV NL AnimAlpha NoFog + // OneSidedAlpha: the same, culled + { 55, { F::BASIC, 0, OPACITY, 0 } }, // OneSidedAlpha VC + { 56, { F::BASIC, UNLIT | NO_VERTEX_COLORS, OPACITY, 0 } }, // OneSidedAlpha NL + { 57, { F::BASIC, UNLIT, OPACITY, ANIM_ALPHA } }, // OneSidedAlpha NL VC + { 58, { F::BASIC, UNLIT | NO_TEXTURE, OPACITY, ANIM_ALPHA } }, // OneSidedAlpha NL VC NT + { 59, { F::BASIC, 0, OPACITY, UV_ANIM } }, // OneSidedAlpha AnimUV V Skinned + { 60, { F::BASIC, 0, OPACITY, 0 } }, // OneSidedAlpha VC Skinned + { 61, { F::BASIC, UNLIT | NO_VERTEX_COLORS, OPACITY, 0 } }, // OneSidedAlpha NL Skinned + { 62, { F::BASIC, UNLIT, OPACITY, ANIM_ALPHA } }, // OneSidedAlpha NL VC Skinned + { 63, { F::BASIC, UNLIT | NO_TEXTURE, OPACITY, ANIM_ALPHA } }, // OneSidedAlpha NL VC NT Skinned + { 64, { F::BASIC, 0, OPACITY, UV_ANIM } }, // OneSidedAlpha AnimUV V + { 68, { F::BASIC, UNLIT, OPACITY, ANIM_ALPHA } }, // OneSidedAlpha NL AnimAlpha + // BasicShaders + { 11, { F::BASIC, UNLIT | NO_TEXTURE, OPACITY, ANIM_ALPHA } }, // VertColor_NoLight_NoTex_AnimAlpha + { 15, { F::BASIC, UNLIT, OPACITY, 0 } }, // VC_NoLighting_2D + { 16, { F::BASIC, UNLIT | NO_TEXTURE, OPACITY, 0 } }, // VC_NL_NoTex_2D + { 17, { F::BASIC, UNLIT, OPACITY, 0 } }, // TV Screen (its static and flicker left out) + { 18, { F::BASIC, UNLIT, OPACITY, 0 } }, // Head Icon + { 23, { F::BASIC, UNLIT, OPACITY, 0 } }, // Over Everything (Unlit) + { 24, { F::BASIC, UNLIT, OPACITY, NO_FOG | BLEND } }, // Fogless GrayBubble + { 32, { F::BASIC, UNLIT | NO_VERTEX_COLORS | MATERIAL_COLOR, OPACITY, 0 } }, // Basic NL Material + { 33, { F::BASIC, UNLIT | NO_TEXTURE, OPACITY, ANIM_ALPHA } }, // Basic NL VC NT + { 34, { F::BASIC, UNLIT | NO_VERTEX_COLORS, OPACITY, 0 } }, // Basic NL + { 35, { F::BASIC, UNLIT, OPACITY, 0 } }, // Basic NL VC + { 36, { F::BASIC, UNLIT | NO_VERTEX_COLORS, OPACITY, UV_ANIM } }, // Basic NL UVAnim + { 37, { F::BASIC, NO_TEXTURE, OPACITY, 0 } }, // Basic VC NT + { 38, { F::BASIC, 0, OPACITY, 0 } }, // Basic VC + { 39, { F::BASIC, 0, OPACITY, UV_ANIM } }, // Basic VC UVAnim + { 49, { F::BASIC, 0, OPACITY, 0 } }, // Experimental Stub + { 65, { F::BASIC, 0, OPACITY, BASIC_EMISSIVE } }, // VC_Texture_Emissive + { 80, { F::BASIC, UNLIT | NO_TEXTURE, OPACITY, 0 } }, // Basic NL NT + { 82, { F::BASIC, UNLIT | NO_TEXTURE, OPACITY, NO_BLEND | NO_FOG } }, // Opaque NL VC NT NoFog + { 83, { F::BASIC, UNLIT | NO_VERTEX_COLORS, OPACITY, NO_BLEND | NO_FOG } }, // Opaque NL NoFog + { 84, { F::BASIC, UNLIT, OPACITY, NO_BLEND | NO_FOG } }, // Opaque NL VC NoFog + { 85, { F::BASIC, NO_TEXTURE, OPACITY, NO_BLEND | NO_FOG } }, // Opaque VC NT NoFog + { 86, { F::BASIC, 0, OPACITY, NO_BLEND | NO_FOG } }, // Opaque VC NoFog + { 87, { F::BASIC, UNLIT, OPACITY, ADDITIVE } }, // Additive NoLight VertColor + { 91, { F::BASIC, UNLIT, OPACITY, BLEND } }, // Pet Taming Imagination Cloud + { 94, { F::BASIC, 0, OPACITY, 0 } }, // Basic + { 108, { F::BASIC, UNLIT | NO_VERTEX_COLORS | MATERIAL_COLOR, OPACITY, 0 } }, // Over Everything Material Unlit + // Two layers (TwoLayersAdded_PS in BasicShaders.fx; the client ships no Technique_TwoLayersBlended_* shader, + // so the blended ones follow the meshes' data: the dark texture under the base one by the vertex alpha, as + // Avant Gardens' snow caps and grass fade into rock by it) + { 93, { F::BASIC, UNLIT | TWO_LAYERS_ADDED, OPACITY, UV_ANIM } }, // Two Textures Added NL VC AnimUV + { 105, { F::BASIC, UNLIT | TWO_LAYERS_BLENDED, OPACITY, UV_ANIM } }, // Two Layers Blended NL VC AnimUV + { 106, { F::BASIC, TWO_LAYERS_BLENDED, OPACITY, UV_ANIM } }, // Two Layers Blended VC AnimUV + { 107, { F::BASIC, TWO_LAYERS_ADDED, OPACITY, UV_ANIM } }, // Two Layers Added VC AnimUV + // Metallic.fx: both load their reflection cubes themselves (textures/metal) + { 98, { F::METAL, REFLECTIVE, OPACITY, 0 } }, // Polished Metal + { 99, { F::METAL, REFLECTIVE | BRUSHED, OPACITY, 0 } }, // Brushed Steel + { 100, { F::METAL, REFLECTIVE | BRUSHED, OPACITY, 0 } }, // Brushed Steel Item + { 6, { F::CLEAR_PLASTIC, 0, OPACITY, BLEND } }, // Clear Plastic + { 51, { F::BRICK_WATER, 0, OPACITY, 0 } }, // BrickWater + // Ocean.fx + { 69, { F::OCEAN, 0, OPACITY, UV_ANIM } }, // Distortion (Ocean) + { 89, { F::OCEAN, 0, OPACITY, UV_ANIM } }, // Distortion Directional (Ocean) + { 90, { F::OCEAN, UNLIT, OPACITY, UV_ANIM | OCEAN_FX } }, // Distortion FX (Ocean) + { 95, { F::OCEAN, 0, OPACITY, UV_ANIM | BLEND } }, // Distortion NoDepth (Ocean) (Alpha) + { 101, { F::OCEAN, UNLIT, OPACITY, UV_ANIM } }, // Distortion (Ocean) Unlit + { 78, { F::FLAT_SURF, 0, OPACITY, UV_ANIM } }, // Flat Surf + // Drawn by other passes, not in the world: footprints, post-processing, drop shadows, Technique_Undefined + { 21, { F::BASIC, 0, OPACITY, NOT_DRAWN } }, // Model Footprint + { 71, { F::BASIC, 0, OPACITY, NOT_DRAWN } }, // PostProcess Gray Bubble + { 74, { F::BASIC, 0, OPACITY, NOT_DRAWN } }, // Drop Shadow + { 79, { F::BASIC, 0, OPACITY, NOT_DRAWN } }, // Post Process Gray Bubble Interior Ghost + { 96, { F::BASIC, 0, OPACITY, NOT_DRAWN } }, // Undefined + }; + } + + const char* FamilyName(eShaderFamily family) { + switch (family) { + case eShaderFamily::FIXED_FUNCTION: return "fixed"; + case eShaderFamily::LEGO: return "lego"; + case eShaderFamily::BASIC: return "basic"; + case eShaderFamily::METAL: return "metal"; + case eShaderFamily::CLEAR_PLASTIC: return "clearPlastic"; + case eShaderFamily::OCEAN: return "ocean"; + case eShaderFamily::FLAT_SURF: return "flatSurf"; + case eShaderFamily::BRICK_WATER: return "brickWater"; + case eShaderFamily::DARKLING: return "darkling"; + case eShaderFamily::TERRAIN: return "terrain"; } + return "lego"; + } + + ShaderTechnique TechniqueFor(int32_t shader) { + for (const auto& row : TECHNIQUES) { + if (row.shader == shader) return row.technique; + } + return ShaderTechnique{}; // the LEGO shader, as the client falls back to + } + + std::string TechniquesJson(const std::vector& shaders) { + nlohmann::json out = nlohmann::json::object(); + for (const auto shader : shaders) { + const auto technique = TechniqueFor(shader); + const char* alpha = technique.textureAlpha == eTextureAlpha::DECAL ? "decal" : technique.textureAlpha == eTextureAlpha::IGNORED ? "ignored" : "opacity"; + out[std::to_string(shader)] = { {"family", FamilyName(technique.family)}, {"look", technique.look}, {"alpha", alpha}, {"flags", technique.flags} }; + } + return out.dump(); + } + + eTextureAlpha TextureAlphaFor(int32_t shader) { + return TechniqueFor(shader).textureAlpha; } uint16_t ShaderLookFor(int32_t shader) { - // 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: - 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; - // (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: - return UNLIT | NO_TEXTURE; - // 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, 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 101: - return UNLIT; - // Polished Metal (Technique_Lighting_PolishedMetal_VertColor in Metallic.fx) and Brushed Steel (its noise - // in object space); both load their reflection textures themselves - case 98: - return REFLECTIVE; - case 99: - return REFLECTIVE | BRUSHED; - // LEGO-Emissive - case 53: - return EMISSIVE; - default: - return 0; - } + return TechniqueFor(shader).look; } std::optional Parse(std::string_view data, uint32_t lod, std::string& error) { diff --git a/dCommon/NifFile.h b/dCommon/NifFile.h index 220acb03c..98bab8e8f 100644 --- a/dCommon/NifFile.h +++ b/dCommon/NifFile.h @@ -78,7 +78,7 @@ namespace NifFile { // The shader (gameValue) a multishader part is drawn with, from its tag's gameValue: outside 3..108 the LEGO shader int32_t MultishaderPart(std::optional tagShader); - // What a texture's alpha does under a shader (mapShaders.gameValue); -1 is fixed function (opacity) + // What a texture's alpha does under a shader (mapShaders.gameValue, TechniqueFor); -1 is fixed function (opacity) eTextureAlpha TextureAlphaFor(int32_t shader); /** @@ -112,6 +112,66 @@ namespace NifFile { // eShaderLook bits of a shader (mapShaders.gameValue); 0 for the usual lit look and for fixed function uint16_t ShaderLookFor(int32_t shader); + /** + * The client's shader families (res/shaders/*.fx), each drawn by one program in the dashboard's 3D views + * (static/js/game-shaders.js): FIXED_FUNCTION Gamebryo's own lighting with the material; LEGO LEGOPPLighting (hemisphere + * lit sun, fresnel rim, specular, a faint environment reflection); BASIC BasicShaders and AlphaAsAlpha (sun * N.L + + * ambient per vertex, or unlit, times vertex color and texture); METAL Metallic.fx; CLEAR_PLASTIC ClearPlastic.fx; + * OCEAN Ocean.fx's Distortion techniques (warped texture layers); FLAT_SURF Ocean.fx's Flat Surf; BRICK_WATER + * BrickWater.fx; DARKLING LEGOPPLighting's Darkling techniques (a second, dark texture on its own UV set); TERRAIN + * TerrainDiffuse.fx's mesh techniques. + */ + enum class eShaderFamily : uint8_t { FIXED_FUNCTION, LEGO, BASIC, METAL, CLEAR_PLASTIC, OCEAN, FLAT_SURF, BRICK_WATER, DARKLING, TERRAIN }; + + // Its name in the scenery manifest ("fixed", "lego", "basic", "metal", "clearPlastic", "ocean", "flatSurf", "brickWater", "darkling", "terrain") + const char* FamilyName(eShaderFamily family); + + // What a technique does besides its family and eShaderLook bits (static/js/scenery-core.js TECHNIQUE has the same bits) + enum eTechniqueFlag : uint32_t { + UV_ANIM = 1, // the base texture moves by the .nif's texture transform (g_textureMotion) + DOUBLE_SIDED = 2, // Cullmode none (AlphaAsAlpha) + BLEND = 4, // alpha blended without depth writes + ALPHA_TEST = 8, // cut out by the alpha + ADDITIVE = 16, // added onto what's behind + NO_AMBIENT = 32, // lit as if the ambient light were white + GLOW = 64, // LEGOPP_ApplyGlow: the glow color by the alpha + IGNORE_VERTEX_ALPHA = 128, // ... with the vertex alpha taken as 1 + SUPER_EMISSIVE = 256, // LEGO-Emissive going to ten times the color + GRAYSCALE = 512, // LEGOPP_ApplyGrayscale + SHINY_GLINT = 1024, // a highlight band moving up the object + SPECULAR = 2048, // Darkling with LEGO lighting + NON_DECAL = 4096, // the texture multiplies the vertex colors instead of being laid over them + OCEAN_FX = 8192, // Distortion FX: unlit, the alpha a window set by the material's emissive + RIM_LIGHT = 16384, // Terrain Mesh Rim Light + DIFFUSE_ONLY = 32768, // Terrain Diffuse Map Only: the texture doubled, lit + ANIM_ALPHA = 65536, // NiMaterialProperty's alpha (animated) multiplies the output alpha + BASIC_EMISSIVE = 131072, // Basic_Emissive_PS + NO_FOG = 262144, // the "NoFog" techniques + NOT_DRAWN = 524288, // nothing of it is drawn in the world (post-processing, footprints, drop shadows) + NO_BLEND = 1048576 // no alpha blending ("Opaque" techniques, terrain meshes) + }; + + struct ShaderTechnique { + eShaderFamily family{ eShaderFamily::LEGO }; + uint16_t look{}; // eShaderLook bits + eTextureAlpha textureAlpha{ eTextureAlpha::DECAL }; + uint32_t flags{}; // eTechniqueFlag bits + }; + + /** + * How the client draws with a shader (mapShaders.gameValue): the one table of the game's shaders for the views. + * Every mapShaders gameValue has a row; -1 is fixed function; any other value is the LEGO shader, which the client + * falls back to. + */ + ShaderTechnique TechniqueFor(int32_t shader); + + /** + * The techniques of these shaders (gameValues) as the scenery manifest's "techniques": a JSON object of gameValue -> + * {"family": FamilyName, "look": eShaderLook bits, "alpha": "opacity"|"decal"|"ignored", "flags": eTechniqueFlag + * bits}. + */ + std::string TechniquesJson(const std::vector& shaders); + // 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 5308c3f5a..68c822d5e 100644 --- a/dDashboardServer/routes/Scenery.cpp +++ b/dDashboardServer/routes/Scenery.cpp @@ -208,29 +208,21 @@ namespace { } /** - * How the client's shaders use each model's texture alpha, for the viewer: "shaders" gives each asset's shader - * (the first object drawing it wins; -1 fixed function or not an object), "shaderTags" a multishader part's tag -> - * shader, and "textureAlpha" the shaders whose texture alpha isn't opacity (NifFile::TextureAlphaFor). + * How the client draws each model, for the viewers' game shaders (static/js/game-shaders.js): "shaders" gives each + * asset's shader (the first object drawing it wins; -1 fixed function or not an object), "shaderTags" a multishader + * part's tag -> shader, and "techniques" every shader's technique (NifFile::TechniquesJson, the one table of them). */ void AddShaders(nlohmann::json& manifest, const std::vector& assetShaders) { RenderInfos(); // reads g_ShaderValues manifest["shaders"] = assetShaders; - nlohmann::json tags = nlohmann::json::object(), modes = nlohmann::json::object(); - for (const auto& [id, value] : g_ShaderValues) tags[std::to_string(id)] = value; - auto mode = [&modes](int32_t value) { - const auto alpha = NifFile::TextureAlphaFor(value); - if (alpha != NifFile::eTextureAlpha::OPACITY) modes[std::to_string(value)] = alpha == NifFile::eTextureAlpha::DECAL ? "decal" : "ignored"; - }; - for (const auto& [id, value] : g_ShaderValues) mode(value); - mode(NifFile::LEGO_SHADER); - // What each shader leaves out of the lit look (NifFile::eShaderLook bits), for the ones that do - nlohmann::json looks = nlohmann::json::object(); + nlohmann::json tags = nlohmann::json::object(); + std::set values{ -1, NifFile::LEGO_SHADER }; for (const auto& [id, value] : g_ShaderValues) { - if (const auto look = NifFile::ShaderLookFor(value)) looks[std::to_string(value)] = look; + tags[std::to_string(id)] = value; + values.insert(value); } manifest["shaderTags"] = std::move(tags); - manifest["textureAlpha"] = std::move(modes); - manifest["shaderLooks"] = std::move(looks); + manifest["techniques"] = nlohmann::json::parse(NifFile::TechniquesJson({ values.begin(), values.end() })); manifest["multishader"] = NifFile::MULTISHADER; manifest["defaultShader"] = NifFile::LEGO_SHADER; } @@ -245,15 +237,16 @@ 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. + * 3: dark textures and the UV set each texture names. 4: the game's shaders draw the models (manifest + * "techniques"), vertex colors go to them as stored. */ - constexpr uint32_t FORMAT_VERSION = 3; + constexpr uint32_t FORMAT_VERSION = 4; // 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)}, + {"ambient", triple(lighting.ambient)}, {"light", triple(lighting.light)}, {"lightVec", triple(lighting.lightVec)}, {"specular", triple(lighting.specular)}, {"upperHemi", triple(lighting.upperHemi)}, {"fogColor", triple(lighting.fogColor)}, {"fogNear", Round(lighting.fogNear, 10.0)}, {"fogFar", Round(lighting.fogFar, 10.0)} }; @@ -445,6 +438,8 @@ namespace { } return nlohmann::json{ {"zone", zoneId}, {"sky", -1}, {"assets", assets}, {"distance", FLAIR_DISTANCE}, {"colorScale", 1.0 / 63.0}, {"lighting", scenery.lighting}, {"format", FORMAT_VERSION}, + // Flair.fx for all of them: (0.85 * sun + ambient) * the flair's tint, whatever their facing + {"technique", { {"family", "flair"}, {"look", 0}, {"alpha", "opacity"}, {"flags", 0} }}, {"objects", { {"asset", assetOf}, {"pos", positions}, {"rot", rotations}, {"scale", scales}, {"color", colors} }} }.dump(); } @@ -956,7 +951,46 @@ namespace Scenery { }); } + /** + * The environment textures the client's shaders load themselves, by the name the viewers ask for them: the default + * reflection cube (LEGOPPLighting, ClearPlastic) and Metallic.fx's cubes and noise. + */ + static const std::map& EnvironmentTextures() { + static const std::map textures{ + { "reflection", "textures/env/default_reflection.dds" }, + { "polished", "textures/metal/metal_reflection_polished.dds" }, + { "brushed", "textures/metal/metal_reflection_brushed.dds" }, + { "brushedNoise", "textures/metal/metal_reflection_brushed_noise.dds" } + }; + return textures; + } + void RegisterRoutes() { + Route(eHTTPMethod::GET, "/api/scenery/env/:name", 0, + "An environment texture the client's shaders load themselves, as a DDS file: reflection (the default reflection cube), polished, brushed (the metal cubes) or brushedNoise", + [](HTTPReply& reply, const HTTPContext& context) { + const std::string name(PathSegment(context.path, 3)); + const auto& textures = EnvironmentTextures(); + const auto it = textures.find(name); + if (it == textures.end()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such environment texture"); + // Read once: a few megabytes the views ask for with every zone + static std::mutex mutex; + static std::map> cache; + std::shared_ptr bytes; + { + std::lock_guard lock(mutex); + if (const auto cached = cache.find(name); cached != cache.end()) bytes = cached->second; + } + if (!bytes) { + auto read = ClientAssets::ReadResFile(it->second); + if (!read) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "The client has no such texture"); + bytes = std::make_shared(std::move(*read)); + std::lock_guard lock(mutex); + cache.try_emplace(name, bytes); + } + Binary(reply, *bytes); + }); + Route(eHTTPMethod::GET, "/api/scenery/:zone/mesh/:asset", 0, "Model `asset` of a zone's scenery (see the scenery routes of properties and /world3d), converted from the client's .nif. Query: ?lod=0 (most detailed) to 3", [](HTTPReply& reply, const HTTPContext& context) { diff --git a/dDashboardServer/static/js/scenery-core.js b/dDashboardServer/static/js/scenery-core.js index de4a2bf2c..a6c73e0ce 100644 --- a/dDashboardServer/static/js/scenery-core.js +++ b/dDashboardServer/static/js/scenery-core.js @@ -40,18 +40,6 @@ export function parseModel(buffer) { return { header, meshes }; } -/** - * What a mesh's texture alpha does in the game, from the manifest's shader data (Scenery.cpp AddShaders): the - * client's shader decides, not the .nif. 'opacity' see-through where the alpha is; 'decal' the texture is laid over - * the vertex colors by its alpha (LEGO shaders); 'ignored' the alpha does nothing. A multishader model's parts name - * their shader in their node names (mesh.shaderTag); a tag the client can't use falls back to the LEGO shader. - */ -export function textureAlphaMode(manifest, asset, mesh) { - if (!manifest || !manifest.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 @@ -112,22 +100,57 @@ float glitterFleck(vec2 uv) { return { update(seconds) { uniforms.glitterTime.value = seconds; } }; } +// NifFile::eTechniqueFlag bits (what a technique does besides its family and eShaderLook bits) +export const TECHNIQUE = { + UV_ANIM: 1, DOUBLE_SIDED: 2, BLEND: 4, ALPHA_TEST: 8, ADDITIVE: 16, NO_AMBIENT: 32, GLOW: 64, IGNORE_VERTEX_ALPHA: 128, + SUPER_EMISSIVE: 256, GRAYSCALE: 512, SHINY_GLINT: 1024, SPECULAR: 2048, NON_DECAL: 4096, OCEAN_FX: 8192, RIM_LIGHT: 16384, + DIFFUSE_ONLY: 32768, ANIM_ALPHA: 65536, BASIC_EMISSIVE: 131072, NO_FOG: 262144, NOT_DRAWN: 524288, NO_BLEND: 1048576 +}; + +// A technique the manifest doesn't name: the LEGO shader's, as the client falls back to it +const LEGO_TECHNIQUE = { family: 'lego', look: 0, alpha: 'decal', flags: 0 }; +const FIXED_TECHNIQUE = { family: 'fixed', look: 0, alpha: 'opacity', flags: 0 }; + /** - * How a mesh is drawn under the game's shaders, when the manifest has the zone's lighting: {lit, texture, - * 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), whether it is metal - * ('polished', 'brushed' or null: an environment reflection tinted by the vertex color) and whether it glows - * (LEGO-Emissive: the lit color goes to the vertex color by the vertex alpha times the material's emissive red, so - * the vertex alpha is no opacity). Null without - * lighting in the manifest (older servers), for the viewer's own lights. + * The technique (NifFile::TechniqueFor, the manifest's "techniques" by gameValue) a mesh of a model is drawn with: + * {shader, family, look, alpha, flags}. A manifest-wide "technique" (the flairs') is every mesh's; without a shader the + * mesh is fixed function; a shader the table lacks is drawn as LEGO. + */ +export function techniqueOf(manifest, asset, mesh) { + if (manifest && manifest.technique) return { shader: null, ...manifest.technique }; + const shader = shaderOf(manifest, asset, mesh); + if (shader === null || shader < 0) return { shader, ...FIXED_TECHNIQUE }; + const known = manifest.techniques && manifest.techniques[shader]; + return { shader, ...(known || LEGO_TECHNIQUE) }; +} + +/** + * What a texture's alpha does in the game (the technique's, NifFile::eTextureAlpha): 'opacity' see-through where the + * alpha is; 'decal' the texture is laid over the vertex colors by its alpha (LEGO shaders); 'ignored' it does nothing. + */ +export function textureAlphaMode(manifest, asset, mesh) { + return techniqueOf(manifest, asset, mesh).alpha || 'opacity'; +} + +/** + * How a mesh is drawn under the game's shaders, when the manifest has the zone's lighting: {family (game-shaders.js), + * lit, texture, vertexColors, material, layers, metal, emissive, textureAlpha, uvAnim, flags, blend, doubleSided, + * hidden} — 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), how a two layer shader puts + * its dark texture with the base one ('blended', 'added' or null), whether it is metal ('polished', 'brushed' or null), + * whether it glows (LEGO-Emissive: the vertex alpha is then no opacity), whether its texture moves as the .nif's + * texture transform says, its blending ('nif': as NiAlphaProperty says; 'blend': see-through without depth writes; + * 'test': cut out; 'additive'; 'opaque') and whether the game draws it in the world at all (hidden: post-processing + * and shadow shaders). 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; + const technique = techniqueOf(manifest, asset, mesh); + const fixedFunction = technique.family === 'fixed'; + const bits = technique.look || 0; + const flags = technique.flags || 0; return { + family: technique.family, 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 @@ -135,10 +158,34 @@ export function gameLook(manifest, asset, mesh) { material: fixedFunction || !!(bits & SHADER_LOOK.MATERIAL_COLOR), layers: bits & SHADER_LOOK.TWO_LAYERS_BLENDED ? 'blended' : bits & SHADER_LOOK.TWO_LAYERS_ADDED ? 'added' : null, metal: metalOf(bits), - emissive: !!(bits & SHADER_LOOK.EMISSIVE) + emissive: !!(bits & SHADER_LOOK.EMISSIVE), + textureAlpha: technique.alpha || 'opacity', + uvAnim: !!(flags & TECHNIQUE.UV_ANIM), + flags, + blend: flags & TECHNIQUE.ADDITIVE ? 'additive' : flags & TECHNIQUE.BLEND ? 'blend' : flags & TECHNIQUE.ALPHA_TEST ? 'test' : flags & TECHNIQUE.NO_BLEND ? 'opaque' : 'nif', + doubleSided: !!(flags & TECHNIQUE.DOUBLE_SIDED), + hidden: !!(flags & TECHNIQUE.NOT_DRAWN) }; } +/** + * How a mesh drawn with `look` is blended: {transparent, depthWrite, additive, alphaCutoff, doubleSided}. Most of the + * game's files have alpha blending switched on; it only shows where something is see-through (`seeThrough`: the + * material, a vertex or the texture's alpha as opacity). Blended meshes still write depth, as Gamebryo's default does, + * unless the technique turns that off. + */ +export function blendingOf(look, mesh, seeThrough) { + const cutoff = mesh.test >= 0 ? Math.max(mesh.test / 255, 0.01) : 0; + const doubleSided = !!mesh.doubleSided || !!(look && look.doubleSided); + switch (look ? look.blend : 'nif') { + case 'additive': return { transparent: true, depthWrite: false, additive: true, alphaCutoff: 0, doubleSided }; + case 'blend': return { transparent: true, depthWrite: false, additive: false, alphaCutoff: cutoff, doubleSided }; + case 'test': return { transparent: false, depthWrite: true, additive: false, alphaCutoff: cutoff || 0.5, doubleSided }; + case 'opaque': return { transparent: false, depthWrite: true, additive: false, alphaCutoff: 0, doubleSided }; + default: return { transparent: !!(mesh.blend && seeThrough), depthWrite: true, additive: false, alphaCutoff: cutoff, doubleSided }; + } +} + // A shader's metal from its eShaderLook bits: 'polished', 'brushed' or null export function metalOf(bits) { if (!(bits & SHADER_LOOK.REFLECTIVE)) return null; @@ -262,6 +309,37 @@ export function parseDds(buffer, maxSize = 4096) { return image; } +/** + * A DDS cube map (the client's environment cubes, textures/env and textures/metal) as six RGBA faces no larger than + * maxSize: {faces: [{format: 'RGBA', width, height, data}]} in the file's order (+X, -X, +Y, -Y, +Z, -Z, as WebGL + * takes them). With `plain`, a DDS that isn't a cube comes back as its one face instead; null otherwise. + */ +export function parseDdsCube(buffer, maxSize = 256, plain = false) { + if (buffer.byteLength < 128) return null; + const view = new DataView(buffer); + if (view.getUint32(0, true) !== 0x20534444) return null; + const isCube = (view.getUint32(112, true) & 0x200) !== 0; + if (isCube === plain) return null; + const height = view.getUint32(12, true), width = view.getUint32(16, true); + const mipCount = Math.max(1, view.getUint32(28, true)); + const pfFlags = view.getUint32(80, true); + const format = FOURCC[view.getUint32(84, true)]; + if (!(pfFlags & 0x4) || !format || !width || !height || width > 4096 || height > 4096) return null; + const blockBytes = format === 'DXT1' ? 8 : 16; + const levelBytes = (w, h) => Math.max(1, (w + 3) >> 2) * Math.max(1, (h + 3) >> 2) * blockBytes; + let faceBytes = 0; + for (let i = 0, w = width, h = height; i < mipCount; i++, w = Math.max(1, w >> 1), h = Math.max(1, h >> 1)) faceBytes += levelBytes(w, h); + const count = isCube ? 6 : 1; + if (128 + faceBytes * count > buffer.byteLength) return null; + const faces = []; + for (let f = 0; f < count; f++) { + const data = new Uint8Array(buffer, 128 + f * faceBytes, levelBytes(width, height)); + const rgba = decodeDxt(format, width, height, data); + faces.push(downscale({ format: 'RGBA', width, height, levels: [{ width, height, data: rgba }] }, maxSize).levels[0]); + } + return plain ? { format: 'RGBA', ...faces[0] } : { faces: faces.map((face) => ({ format: 'RGBA', ...face })) }; +} + // Halve an RGBA image (box filter) until it fits maxSize function downscale(image, maxSize) { let { width, height, data } = image.levels[0]; diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 0dab41635..d918b9486 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -83,9 +83,9 @@ 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 +# The 3D views' shader lookups (static/js/scenery-core.js) against the server's bits (NifFile.h), 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") + "${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/scenery-core.js" "${PROJECT_SOURCE_DIR}/dCommon/NifFile.h") endif() diff --git a/tests/dWebTests/NifFileTests.cpp b/tests/dWebTests/NifFileTests.cpp index 84aafa745..c82bae71d 100644 --- a/tests/dWebTests/NifFileTests.cpp +++ b/tests/dWebTests/NifFileTests.cpp @@ -587,6 +587,58 @@ TEST(NifFileTests, KnowsWhichShadersUseTextureAlphaAsOpacity) { EXPECT_EQ(NifFile::TextureAlphaFor(-1), eTextureAlpha::OPACITY); // fixed function: NiAlphaProperty as Gamebryo does } +TEST(NifFileTests, KnowsEachShadersTechniqueFamily) { + using NifFile::eShaderFamily; + const auto family = [](int32_t shader) { return NifFile::TechniqueFor(shader).family; }; + const auto flags = [](int32_t shader) { return NifFile::TechniqueFor(shader).flags; }; + EXPECT_EQ(family(-1), eShaderFamily::FIXED_FUNCTION); + // The ones most of the zones' objects use: LEGO, Basic VC, "Basic", VertColor_Alpha, LEGO NoAmbient + EXPECT_EQ(family(NifFile::LEGO_SHADER), eShaderFamily::LEGO); + EXPECT_EQ(family(38), eShaderFamily::BASIC); + EXPECT_EQ(family(94), eShaderFamily::BASIC); + EXPECT_EQ(family(7), eShaderFamily::BASIC); + EXPECT_TRUE(flags(7) & NifFile::DOUBLE_SIDED); // AlphaAsAlpha: Cullmode none + EXPECT_EQ(family(88), eShaderFamily::LEGO); + EXPECT_TRUE(flags(88) & NifFile::NO_AMBIENT); + EXPECT_EQ(family(3), eShaderFamily::TERRAIN); + EXPECT_TRUE(flags(3) & NifFile::RIM_LIGHT); + // Moving textures, water, metal, glass, darklings + EXPECT_TRUE(flags(30) & NifFile::UV_ANIM); // LEGO-AnimUV + EXPECT_TRUE(flags(70) & NifFile::UV_ANIM); // ScrollingUV_NoLight_AnimAlpha + EXPECT_FALSE(flags(38) & NifFile::UV_ANIM); + EXPECT_EQ(family(69), eShaderFamily::OCEAN); + EXPECT_TRUE(flags(90) & NifFile::OCEAN_FX); + EXPECT_EQ(family(98), eShaderFamily::METAL); + EXPECT_EQ(family(99), eShaderFamily::METAL); + EXPECT_EQ(family(6), eShaderFamily::CLEAR_PLASTIC); + EXPECT_TRUE(flags(6) & NifFile::BLEND); + EXPECT_EQ(family(75), eShaderFamily::DARKLING); + EXPECT_TRUE(flags(76) & NifFile::SPECULAR); + EXPECT_TRUE(flags(22) & NifFile::SUPER_EMISSIVE); + EXPECT_TRUE(flags(87) & NifFile::ADDITIVE); + EXPECT_TRUE(flags(74) & NifFile::NOT_DRAWN); // Drop Shadow + // A value the table lacks is the LEGO shader, as the client falls back to it + EXPECT_EQ(family(4242), eShaderFamily::LEGO); + EXPECT_EQ(NifFile::TextureAlphaFor(4242), NifFile::eTextureAlpha::DECAL); +} + +TEST(NifFileTests, WritesTechniquesForTheManifest) { + const auto json = nlohmann::json::parse(NifFile::TechniquesJson({ -1, 5, 99, 87 })); + ASSERT_EQ(json.size(), 4u); + EXPECT_EQ(json["-1"]["family"], "fixed"); + EXPECT_EQ(json["5"]["family"], "lego"); + EXPECT_EQ(json["5"]["alpha"], "decal"); + EXPECT_EQ(json["99"]["family"], "metal"); + EXPECT_EQ(json["99"]["look"], NifFile::REFLECTIVE | NifFile::BRUSHED); + EXPECT_EQ(json["87"]["flags"], NifFile::ADDITIVE); + EXPECT_EQ(json["87"]["look"], NifFile::UNLIT); + for (const auto family : { "fixed", "lego", "basic", "metal", "clearPlastic", "ocean", "flatSurf", "brickWater", "darkling", "terrain" }) { + bool named = false; + for (int i = 0; i <= static_cast(NifFile::eShaderFamily::TERRAIN); i++) named |= std::string(NifFile::FamilyName(static_cast(i))) == family; + EXPECT_TRUE(named) << family; + } +} + TEST(NifFileTests, PassesMultishaderTagsDownToMeshes) { NifBuilder nif; auto rootAv = Av(0, { 0, 0, 0 }, IDENTITY, 1.0f, {}); diff --git a/tests/dWebTests/scenery-core.test.mjs b/tests/dWebTests/scenery-core.test.mjs index e5552511e..3a8874230 100644 --- a/tests/dWebTests/scenery-core.test.mjs +++ b/tests/dWebTests/scenery-core.test.mjs @@ -1,8 +1,9 @@ -// 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 +// The 3D views' shader lookups (static/js/scenery-core.js): which technique draws a mesh and what it uses. +// Run by ctest: node scenery-core.test.mjs [NifFile.h] import { pathToFileURL } from 'node:url'; +import { readFileSync } from 'node:fs'; -const [modulePath] = process.argv.slice(2); +const [modulePath, nifHeader] = process.argv.slice(2); const S = await import(pathToFileURL(modulePath).href); let failures = 0; const same = (actual, expected, what) => { @@ -12,16 +13,24 @@ const same = (actual, expected, what) => { } }; +// A manifest as Scenery.cpp writes it: "techniques" from NifFile::TechniquesJson +const L = S.SHADER_LOOK, T = S.TECHNIQUE; 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 }, + techniques: { + '-1': { family: 'fixed', look: 0, alpha: 'opacity', flags: 0 }, + 5: { family: 'lego', look: 0, alpha: 'decal', flags: 0 }, + 33: { family: 'basic', look: L.UNLIT | L.NO_TEXTURE, alpha: 'opacity', flags: T.ANIM_ALPHA }, + 38: { family: 'basic', look: 0, alpha: 'opacity', flags: 0 } + }, 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 }; +const pick = (look, keys) => Object.fromEntries(keys.map((k) => [k, look[k]])); +const BASICS = ['family', 'lit', 'texture', 'vertexColors', 'material', 'layers', 'metal', 'emissive']; // 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'); @@ -32,17 +41,41 @@ 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'); +// Techniques: the manifest's table, fixed function without a shader, the LEGO shader for one it lacks +same(S.techniqueOf(manifest, 0, {}).family, 'basic', 'Basic VC technique'); +same(S.techniqueOf(manifest, 2, {}).family, 'fixed', 'fixed function technique'); +same(S.techniqueOf({ ...manifest, shaders: [77] }, 0, {}), { shader: 77, family: 'lego', look: 0, alpha: 'decal', flags: 0 }, 'unknown shader is LEGO'); +same(S.techniqueOf({ ...manifest, technique: { family: 'flair', look: 0, alpha: 'opacity', flags: 0 } }, 0, {}).family, 'flair', 'manifest-wide technique'); + // 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, layers: null, metal: null, emissive: false }, 'Basic VC'); +same(pick(S.gameLook(manifest, 1, { ...colored, shaderTag: 30 }), BASICS), { family: 'basic', lit: true, texture: true, vertexColors: true, material: false, layers: null, metal: null, emissive: 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, layers: null, metal: null, emissive: false }, 'Basic NL VC NT'); +same(pick(S.gameLook(manifest, 3, colored), BASICS), { family: 'basic', lit: false, texture: false, vertexColors: true, material: false, layers: null, metal: null, emissive: 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, layers: null, metal: null, emissive: false }, 'fixed function'); +same(pick(S.gameLook(manifest, 2, { ...colored, vertexColors: 0 }), BASICS), { family: 'fixed', lit: true, texture: true, vertexColors: false, material: true, layers: null, metal: null, emissive: false }, 'fixed function'); // Without the zone's lighting the viewer lights scenery itself same(S.gameLook({ ...manifest, lighting: null }, 0, colored), null, 'no lighting'); +// What the flags turn into: moving textures, both sides, blending, not drawn +const flagged = (flags, family = 'basic') => S.gameLook({ ...manifest, shaders: [1], techniques: { 1: { family, look: 0, alpha: 'opacity', flags } } }, 0, colored); +same(flagged(T.UV_ANIM).uvAnim, true, 'UV animation'); +same(flagged(0).uvAnim, false, 'still texture'); +same(flagged(T.DOUBLE_SIDED).doubleSided, true, 'AlphaAsAlpha both sides'); +same([flagged(0).blend, flagged(T.BLEND).blend, flagged(T.ALPHA_TEST).blend, flagged(T.ADDITIVE).blend, flagged(T.NO_BLEND).blend], ['nif', 'blend', 'test', 'additive', 'opaque'], 'blend modes'); +same(flagged(T.NOT_DRAWN).hidden, true, 'not drawn'); +same(flagged(T.ANIM_ALPHA).flags & T.ANIM_ALPHA, T.ANIM_ALPHA, 'flags kept'); + +// Blending as the look and the mesh say +same(S.blendingOf(null, { blend: true, test: -1 }, true), { transparent: true, depthWrite: true, additive: false, alphaCutoff: 0, doubleSided: false }, 'NiAlphaProperty blend where see-through'); +same(S.blendingOf(flagged(0), { blend: true, test: -1 }, false).transparent, false, 'blend on but nothing see-through'); +same(S.blendingOf(flagged(T.BLEND), { blend: false, test: -1 }, false), { transparent: true, depthWrite: false, additive: false, alphaCutoff: 0, doubleSided: false }, 'technique blends'); +same(S.blendingOf(flagged(T.ADDITIVE), { blend: false, test: -1 }, false).additive, true, 'additive'); +same(S.blendingOf(flagged(T.ALPHA_TEST), { blend: true, test: -1 }, true), { transparent: false, depthWrite: true, additive: false, alphaCutoff: 0.5, doubleSided: false }, 'alpha test'); +same(S.blendingOf(flagged(T.NO_BLEND), { blend: true, test: 128 }, true).transparent, false, 'opaque technique'); +same(S.blendingOf(flagged(T.DOUBLE_SIDED), { blend: false, test: -1, doubleSided: false }, false).doubleSided, true, 'Cullmode none'); + // Scene maps: the same terrain as ZoneScenesTests.FindsTheSceneUnderAPosition, as runs of [length, scene] const runs = (bytes) => Buffer.from(bytes).toString('base64'); const map = S.decodeSceneMap({ chunks: [ @@ -58,19 +91,51 @@ same([...S.loadedScenes(scenes, 0)], [0], 'loaded in the global scene'); 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 } }; +const layered = { ...manifest, shaders: [106, 107], techniques: { 106: { family: 'basic', look: L.TWO_LAYERS_BLENDED, alpha: 'opacity', flags: T.UV_ANIM }, 107: { family: 'basic', look: L.TWO_LAYERS_ADDED, alpha: 'opacity', flags: T.UV_ANIM } } }; 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'); // A player model's metal and glow groups (UGC server shader settings): Polished Metal, Brushed Steel, LEGO-Emissive const shiny = { ...manifest, shaders: [9999], shaderTags: { 1: 5, 88: 98, 89: 99, 46: 53 }, - shaderLooks: { 98: S.SHADER_LOOK.REFLECTIVE, 99: S.SHADER_LOOK.REFLECTIVE | S.SHADER_LOOK.BRUSHED, 53: S.SHADER_LOOK.EMISSIVE } }; + techniques: { ...manifest.techniques, 98: { family: 'metal', look: L.REFLECTIVE, alpha: 'opacity', flags: 0 }, 99: { family: 'metal', look: L.REFLECTIVE | L.BRUSHED, alpha: 'opacity', flags: 0 }, + 53: { family: 'lego', look: L.EMISSIVE, alpha: 'opacity', flags: 0 } } }; same(S.gameLook(shiny, 0, { ...colored, shaderTag: 88 }).metal, 'polished', 'polished metal'); same(S.gameLook(shiny, 0, { ...colored, shaderTag: 89 }).metal, 'brushed', 'brushed steel'); +same(S.gameLook(shiny, 0, { ...colored, shaderTag: 89 }).family, 'metal', 'metal family'); same(S.gameLook(shiny, 0, { ...colored, shaderTag: 46 }).emissive, true, 'emissive'); same(S.gameLook(shiny, 0, { ...colored, shaderTag: 1 }).metal, null, 'plastic'); -same([S.metalOf(0), S.metalOf(S.SHADER_LOOK.REFLECTIVE), S.metalOf(S.SHADER_LOOK.REFLECTIVE | S.SHADER_LOOK.BRUSHED)], [null, 'polished', 'brushed'], 'metal of look bits'); +same([S.metalOf(0), S.metalOf(L.REFLECTIVE), S.metalOf(L.REFLECTIVE | L.BRUSHED)], [null, 'polished', 'brushed'], 'metal of look bits'); + +// Environment cubes: a DXT1 cube of six 4x4 faces, one color each, made RGBA +const cube = new Uint8Array(128 + 6 * 8); +const header = new DataView(cube.buffer); +header.setUint32(0, 0x20534444, true); +header.setUint32(12, 4, true); header.setUint32(16, 4, true); header.setUint32(28, 1, true); +header.setUint32(80, 0x4, true); header.setUint32(84, 0x31545844, true); header.setUint32(112, 0xfe00, true); +const faceColors = [0xf800, 0x07e0, 0x001f, 0xffff, 0x0000, 0x8410]; +faceColors.forEach((c, f) => { const at = 128 + f * 8; cube[at] = c & 255; cube[at + 1] = c >> 8; cube[at + 2] = c & 255; cube[at + 3] = c >> 8; }); +const parsed = S.parseDdsCube(cube.buffer, 256); +same(parsed.faces.length, 6, 'six faces'); +same([...parsed.faces[0].data.slice(0, 4)], [255, 0, 0, 255], '+X red'); +same([...parsed.faces[2].data.slice(0, 4)], [0, 0, 255, 255], '+Y blue'); +same([parsed.faces[5].width, parsed.faces[5].height], [4, 4], 'face size'); +same(S.parseDdsCube(cube.buffer, 2).faces[0].width, 2, 'faces made smaller'); +same(S.parseDdsCube(cube.buffer, 256, true), null, 'a cube is no plain texture'); +header.setUint32(112, 0, true); +same(S.parseDdsCube(cube.buffer, 256), null, 'a plain texture is no cube'); +same(S.parseDdsCube(cube.buffer, 256, true).width, 4, 'plain texture'); + +// The flag and look bits are the server's (NifFile.h eTechniqueFlag, eShaderLook) +if (nifHeader) { + const text = readFileSync(nifHeader, 'utf8'); + const bitsOf = (name) => { + const block = text.slice(text.indexOf('enum ' + name)).split('};')[0]; + return Object.fromEntries([...block.matchAll(/^\s*([A-Z_]+) = (\d+)/gm)].map((m) => [m[1], Number(m[2])])); + }; + same(bitsOf('eTechniqueFlag'), T, 'TECHNIQUE matches eTechniqueFlag'); + same(bitsOf('eShaderLook'), L, 'SHADER_LOOK matches eShaderLook'); +} // 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');