feat(dashboard): one table of the game's shader techniques for the 3D views

NifFile::TechniqueFor maps every mapShaders gameValue to the client's
technique family (fixed function, LEGO, Basic/AlphaAsAlpha, metal, clear
plastic, ocean distortion, flat surf, BrickWater, darkling, terrain mesh),
its eShaderLook bits, texture alpha and eTechniqueFlag bits (moving
texture, both sides, blend, alpha test, additive, no ambient, glow,
super emissive, grayscale, shiny glint, not drawn, ...), from res/shaders
and the verified technique setups. Values it lacks are the LEGO shader,
as the client falls back to it. TextureAlphaFor and ShaderLookFor read it.

The scenery manifests carry it as "techniques" (replacing textureAlpha
and shaderLooks), the flairs' manifest a Flair.fx technique, the
lighting its specular color. /api/scenery/env/:name serves the
environment cubes the client's shaders load themselves (default
reflection, polished and brushed metal, brushed noise). Conversion
format 4.

scenery-core.js: techniqueOf, gameLook with the family and flags,
blendingOf, parseDdsCube; its test checks the flag and look bits against
NifFile.h.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 13:38:47 -05:00
parent dfe4cc523a
commit ee9135437f
7 changed files with 515 additions and 123 deletions

View File

@@ -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<int32_t>& 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<Model> Parse(std::string_view data, uint32_t lod, std::string& error) {

View File

@@ -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<int32_t> 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<int32_t>& shaders);
// Where a node is in the model's space: p' = rotation * p + translation (row-major, scale folded in)
struct NodeTransform {
std::array<float, 9> rotation{ 1, 0, 0, 0, 1, 0, 0, 0, 1 };

View File

@@ -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<int32_t>& 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<int32_t> 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<float, 3>& value) { return nlohmann::json{ Round(value[0], 1000.0), Round(value[1], 1000.0), Round(value[2], 1000.0) }; };
return {
{"ambient", triple(lighting.ambient)}, {"light", triple(lighting.light)}, {"lightVec", triple(lighting.lightVec)},
{"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<std::string, std::string>& EnvironmentTextures() {
static const std::map<std::string, std::string> 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<std::string, std::shared_ptr<const std::string>> cache;
std::shared_ptr<const std::string> 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<const std::string>(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) {

View File

@@ -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];

View File

@@ -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()

View File

@@ -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<int>(NifFile::eShaderFamily::TERRAIN); i++) named |= std::string(NifFile::FamilyName(static_cast<NifFile::eShaderFamily>(i))) == family;
EXPECT_TRUE(named) << family;
}
}
TEST(NifFileTests, PassesMultishaderTagsDownToMeshes) {
NifBuilder nif;
auto rootAv = Av(0, { 0, 0, 0 }, IDENTITY, 1.0f, {});

View File

@@ -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 <scenery-core.js>
// 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 <scenery-core.js> [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');