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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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