mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-16 01:24:08 +00:00
Glom models (multishader) drew their trees, rocks, fences and water white. Their conversions kept on disk from before meshes carried their multishader tag were still served (the cache format version was never bumped), so every part fell back to the LEGO shader, whose texture alpha lays the (mostly grey) texture over the vertex colors that hold the actual colors. Browsers also kept those models for a week. - Bump the conversion format so old conversions are made again, and put it in the manifests; the viewers add it to model and texture URLs. - Light scenery as the client's shaders do: the scene's sun and ambient light from its .lvl (read as level_read_lighting_info, 0x0102f8f0, and EnvironmentManager::SetLightEnv, 0x01088aa0, do), per vertex, clamped, instead of the view's own lights, environment map and tone mapping. The zone takes the lighting most of its objects' scenes have. - Programmable shaders read vertex colors and ignore NiMaterialProperty's color and alpha; unlit and untextured shaders (by mapShaders gameValue) leave out lighting or the texture. Fixed function stays as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
/**
|
|
* The scenery loader's decoding, without three.js so it can be tested with node: the server's converted models
|
|
* (NifFile::Encode) and the client's DDS textures (DXT1/3/5 and uncompressed), which the browser decodes itself.
|
|
*/
|
|
|
|
/**
|
|
* A model from /api/scenery/:zone/mesh/:asset: {header, meshes: [{...header entry, positions, normals, uvs, colors,
|
|
* indices}]} with typed arrays viewing the response (normals int8 x3, colors uint8 RGBA, indices uint16).
|
|
*/
|
|
export function parseModel(buffer) {
|
|
const view = new DataView(buffer);
|
|
const length = view.getUint32(0, true);
|
|
const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, length)));
|
|
const base = 4 + length;
|
|
const meshes = header.meshes.map((entry) => {
|
|
let offset = base + entry.offset;
|
|
const n = entry.vertices;
|
|
const mesh = { ...entry };
|
|
mesh.positions = new Float32Array(buffer, offset, n * 3);
|
|
offset += n * 12;
|
|
if (entry.normals) {
|
|
mesh.normals = new Int8Array(buffer, offset, n * 3);
|
|
offset += (n * 3 + 3) & ~3;
|
|
}
|
|
if (entry.uv) {
|
|
mesh.uvs = new Float32Array(buffer, offset, n * 2);
|
|
offset += n * 8;
|
|
}
|
|
if (entry.colors) {
|
|
mesh.colors = new Uint8Array(buffer, offset, n * 4);
|
|
offset += n * 4;
|
|
}
|
|
mesh.indices = new Uint16Array(buffer, offset, entry.indices);
|
|
return mesh;
|
|
});
|
|
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
|
|
* id); a tag the client can't use falls back to the LEGO shader.
|
|
*/
|
|
export function shaderOf(manifest, asset, mesh) {
|
|
if (!manifest || !manifest.shaders) return null;
|
|
const shader = manifest.shaders[asset];
|
|
if (shader === undefined || shader === null) return null;
|
|
if (shader !== manifest.multishader) return shader;
|
|
const tagged = mesh && mesh.shaderTag >= 0 && manifest.shaderTags ? manifest.shaderTags[mesh.shaderTag] : undefined;
|
|
return tagged !== undefined && tagged >= 3 && tagged <= 108 ? tagged : manifest.defaultShader;
|
|
}
|
|
|
|
// NifFile::eShaderLook bits
|
|
export const SHADER_LOOK = { UNLIT: 1, NO_TEXTURE: 2, NO_VERTEX_COLORS: 4, MATERIAL_COLOR: 8 };
|
|
|
|
/**
|
|
* How a mesh is drawn under the game's shaders, when the manifest has the zone's lighting: {lit, texture,
|
|
* vertexColors, material} — whether the scene's sun and ambient light it, its texture and vertex colors are used, and
|
|
* whether its NiMaterialProperty colors are (only fixed function and the "Material" shaders use them). Null without
|
|
* lighting in the manifest (older servers), for the viewer's own lights.
|
|
*/
|
|
export function gameLook(manifest, asset, mesh) {
|
|
if (!manifest || !manifest.lighting) return null;
|
|
const shader = shaderOf(manifest, asset, mesh);
|
|
const fixedFunction = shader === null || shader < 0;
|
|
const bits = fixedFunction || !manifest.shaderLooks ? 0 : manifest.shaderLooks[shader] || 0;
|
|
return {
|
|
lit: !(bits & SHADER_LOOK.UNLIT),
|
|
texture: !(bits & SHADER_LOOK.NO_TEXTURE),
|
|
// Fixed function reads them as NiVertexColorProperty says; the shaders always do, unless they have none
|
|
vertexColors: !!(mesh.colors && !(bits & SHADER_LOOK.NO_VERTEX_COLORS) && (!fixedFunction || mesh.vertexColors !== 0)),
|
|
material: fixedFunction || !!(bits & SHADER_LOOK.MATERIAL_COLOR)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Meshes of a model that look the same (texture, colors, blending, sides, attributes) joined into one, so a model
|
|
* made of many pieces (the zones' "glom" files have over a hundred) costs a few draw calls instead of one per piece.
|
|
* Order is kept otherwise; indices become 32-bit when a joined mesh passes 65535 vertices.
|
|
*/
|
|
export function mergeMeshes(meshes) {
|
|
const groups = new Map();
|
|
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]);
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(mesh);
|
|
}
|
|
return [...groups.values()].map((list) => {
|
|
if (list.length === 1) return list[0];
|
|
const first = list[0];
|
|
const vertices = list.reduce((sum, m) => sum + m.vertices, 0);
|
|
const indexCount = list.reduce((sum, m) => sum + m.indices.length, 0);
|
|
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.colors) out.colors = new Uint8Array(vertices * 4);
|
|
out.indices = vertices > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount);
|
|
let v = 0, i = 0;
|
|
for (const m of list) {
|
|
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.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;
|
|
i += m.indices.length;
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
|
|
// sRGB byte -> linear byte, for vertex colors (three.js takes vertex colors as linear)
|
|
const SRGB_TO_LINEAR = new Uint8Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
const c = i / 255;
|
|
SRGB_TO_LINEAR[i] = Math.round((c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)) * 255);
|
|
}
|
|
export function linearColors(srgb) {
|
|
const out = new Uint8Array(srgb.length);
|
|
for (let i = 0; i < srgb.length; i += 4) {
|
|
out[i] = SRGB_TO_LINEAR[srgb[i]];
|
|
out[i + 1] = SRGB_TO_LINEAR[srgb[i + 1]];
|
|
out[i + 2] = SRGB_TO_LINEAR[srgb[i + 2]];
|
|
out[i + 3] = srgb[i + 3];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const FOURCC = { 0x31545844: 'DXT1', 0x33545844: 'DXT3', 0x35545844: 'DXT5' };
|
|
|
|
/**
|
|
* A DDS file's header and mipmap levels: {format: 'DXT1'|'DXT3'|'DXT5'|'RGBA', width, height, levels: [{width,
|
|
* height, data}]}, starting at the first level no larger than maxSize (so big textures cost less), or null when the
|
|
* file isn't a DDS this reads. Uncompressed files (24 or 32 bits with masks) come back as RGBA, one level.
|
|
*/
|
|
export function parseDds(buffer, maxSize = 4096) {
|
|
if (buffer.byteLength < 128) return null;
|
|
const view = new DataView(buffer);
|
|
if (view.getUint32(0, true) !== 0x20534444) return null; // "DDS "
|
|
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 fourCC = view.getUint32(84, true);
|
|
if (!width || !height || width > 8192 || height > 8192) return null;
|
|
let offset = 128;
|
|
if (pfFlags & 0x4) {
|
|
const format = FOURCC[fourCC];
|
|
if (!format) return null;
|
|
const blockBytes = format === 'DXT1' ? 8 : 16;
|
|
const levels = [];
|
|
let w = width, h = height;
|
|
for (let i = 0; i < mipCount; i++) {
|
|
const size = Math.max(1, (w + 3) >> 2) * Math.max(1, (h + 3) >> 2) * blockBytes;
|
|
if (offset + size > buffer.byteLength) break;
|
|
levels.push({ width: w, height: h, data: new Uint8Array(buffer, offset, size) });
|
|
offset += size;
|
|
w = Math.max(1, w >> 1);
|
|
h = Math.max(1, h >> 1);
|
|
}
|
|
let first = 0;
|
|
while (first < levels.length - 1 && (levels[first].width > maxSize || levels[first].height > maxSize)) first++;
|
|
const chosen = levels.slice(first);
|
|
if (!chosen.length) return null;
|
|
return { format, width: chosen[0].width, height: chosen[0].height, levels: chosen };
|
|
}
|
|
if (!(pfFlags & 0x40)) return null;
|
|
const bits = view.getUint32(88, true);
|
|
if (bits !== 32 && bits !== 24) return null;
|
|
const masks = [92, 96, 100, 104].map((at) => view.getUint32(at, true));
|
|
const hasAlpha = (pfFlags & 0x1) && masks[3];
|
|
const bytes = bits / 8;
|
|
if (offset + width * height * bytes > buffer.byteLength) return null;
|
|
const shift = (mask) => (mask ? 31 - Math.clz32(mask & -mask) : 0);
|
|
const shifts = masks.map(shift);
|
|
const rgba = new Uint8Array(width * height * 4);
|
|
const src = new Uint8Array(buffer, offset, width * height * bytes);
|
|
for (let i = 0, s = 0; i < width * height; i++, s += bytes) {
|
|
const pixel = bytes === 4 ? (src[s] | (src[s + 1] << 8) | (src[s + 2] << 16) | (src[s + 3] << 24)) >>> 0 : src[s] | (src[s + 1] << 8) | (src[s + 2] << 16);
|
|
rgba[i * 4] = (pixel & masks[0]) >>> shifts[0];
|
|
rgba[i * 4 + 1] = (pixel & masks[1]) >>> shifts[1];
|
|
rgba[i * 4 + 2] = (pixel & masks[2]) >>> shifts[2];
|
|
rgba[i * 4 + 3] = hasAlpha ? (pixel & masks[3]) >>> shifts[3] : 255;
|
|
}
|
|
const image = downscale({ format: 'RGBA', width, height, levels: [{ width, height, data: rgba }] }, maxSize);
|
|
image.alpha = !!hasAlpha;
|
|
return image;
|
|
}
|
|
|
|
// Halve an RGBA image (box filter) until it fits maxSize
|
|
function downscale(image, maxSize) {
|
|
let { width, height, data } = image.levels[0];
|
|
while (width > maxSize || height > maxSize) {
|
|
const w = Math.max(1, width >> 1), h = Math.max(1, height >> 1);
|
|
const out = new Uint8Array(w * h * 4);
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
for (let c = 0; c < 4; c++) {
|
|
const at = (dx, dy) => data[((Math.min(y * 2 + dy, height - 1)) * width + Math.min(x * 2 + dx, width - 1)) * 4 + c];
|
|
out[(y * w + x) * 4 + c] = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) >> 2;
|
|
}
|
|
}
|
|
}
|
|
width = w; height = h; data = out;
|
|
}
|
|
return { format: 'RGBA', width, height, levels: [{ width, height, data }] };
|
|
}
|
|
|
|
function color565(value, out, at) {
|
|
out[at] = ((value >> 11) & 31) * 255 / 31 | 0;
|
|
out[at + 1] = ((value >> 5) & 63) * 255 / 63 | 0;
|
|
out[at + 2] = (value & 31) * 255 / 31 | 0;
|
|
}
|
|
|
|
/**
|
|
* One DXT level as RGBA bytes (for GPUs without S3TC support, e.g. most phones). DXT1 blocks whose first color is
|
|
* not greater than the second have a transparent fourth color.
|
|
*/
|
|
export function decodeDxt(format, width, height, data) {
|
|
const out = new Uint8Array(width * height * 4);
|
|
const blockBytes = format === 'DXT1' ? 8 : 16;
|
|
const bw = Math.max(1, (width + 3) >> 2), bh = Math.max(1, (height + 3) >> 2);
|
|
const palette = new Uint8Array(16);
|
|
const alphas = new Uint8Array(16);
|
|
for (let by = 0; by < bh; by++) {
|
|
for (let bx = 0; bx < bw; bx++) {
|
|
const block = (by * bw + bx) * blockBytes;
|
|
const colorAt = format === 'DXT1' ? block : block + 8;
|
|
const c0 = data[colorAt] | (data[colorAt + 1] << 8), c1 = data[colorAt + 2] | (data[colorAt + 3] << 8);
|
|
color565(c0, palette, 0);
|
|
color565(c1, palette, 4);
|
|
palette[3] = palette[7] = palette[11] = palette[15] = 255;
|
|
if (c0 > c1 || format !== 'DXT1') {
|
|
for (let c = 0; c < 3; c++) {
|
|
palette[8 + c] = (2 * palette[c] + palette[4 + c]) / 3 | 0;
|
|
palette[12 + c] = (palette[c] + 2 * palette[4 + c]) / 3 | 0;
|
|
}
|
|
} else {
|
|
for (let c = 0; c < 3; c++) palette[8 + c] = (palette[c] + palette[4 + c]) >> 1;
|
|
palette[12] = palette[13] = palette[14] = palette[15] = 0;
|
|
}
|
|
if (format === 'DXT3') {
|
|
for (let i = 0; i < 16; i++) alphas[i] = ((data[block + (i >> 1)] >> ((i & 1) * 4)) & 15) * 17;
|
|
} else if (format === 'DXT5') {
|
|
const a0 = data[block], a1 = data[block + 1];
|
|
const table = [a0, a1];
|
|
if (a0 > a1) for (let i = 1; i < 7; i++) table.push(((7 - i) * a0 + i * a1) / 7 | 0);
|
|
else { for (let i = 1; i < 5; i++) table.push(((5 - i) * a0 + i * a1) / 5 | 0); table.push(0, 255); }
|
|
// 48 bits of 3-bit indices
|
|
let bits = 0, count = 0, byte = block + 2;
|
|
for (let i = 0; i < 16; i++) {
|
|
if (count < 3) { bits |= data[byte++] << count; count += 8; }
|
|
alphas[i] = table[bits & 7];
|
|
bits >>= 3; count -= 3;
|
|
}
|
|
}
|
|
const indices = data[colorAt + 4] | (data[colorAt + 5] << 8) | (data[colorAt + 6] << 16) | (data[colorAt + 7] << 24);
|
|
for (let i = 0; i < 16; i++) {
|
|
const x = bx * 4 + (i & 3), y = by * 4 + (i >> 2);
|
|
if (x >= width || y >= height) continue;
|
|
const p = ((indices >>> (i * 2)) & 3) * 4;
|
|
const at = (y * width + x) * 4;
|
|
out[at] = palette[p];
|
|
out[at + 1] = palette[p + 1];
|
|
out[at + 2] = palette[p + 2];
|
|
out[at + 3] = format === 'DXT1' ? palette[p + 3] : alphas[i];
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A level chain is complete (usable for mipmapping) when it runs down to 1x1. */
|
|
export function completeChain(levels) {
|
|
const last = levels[levels.length - 1];
|
|
return levels.length > 0 && last.width === 1 && last.height === 1;
|
|
}
|
|
|
|
/**
|
|
* Objects of a manifest grouped by model: Map asset -> [{index, x, y, z, qx, qy, qz, qw, scale}].
|
|
*/
|
|
export function groupObjects(objects) {
|
|
const byAsset = new Map();
|
|
for (let i = 0; i < objects.asset.length; i++) {
|
|
const asset = objects.asset[i];
|
|
if (!byAsset.has(asset)) byAsset.set(asset, []);
|
|
byAsset.get(asset).push({
|
|
index: i, x: objects.pos[i * 3], y: objects.pos[i * 3 + 1], z: objects.pos[i * 3 + 2],
|
|
qx: objects.rot[i * 4], qy: objects.rot[i * 4 + 1], qz: objects.rot[i * 4 + 2], qw: objects.rot[i * 4 + 3], scale: objects.scale[i],
|
|
// A tint per object ([r, g, b, ...]), for the flairs
|
|
color: objects.color ? [objects.color[i * 3], objects.color[i * 3 + 1], objects.color[i * 3 + 2]] : null,
|
|
// The game doesn't draw it (a trigger or blocking volume): only shown on request
|
|
hidden: !!(objects.hidden && objects.hidden[i])
|
|
});
|
|
}
|
|
return byAsset;
|
|
}
|
|
|
|
/** Split instances into square cells `size` wide (by x and z): Map "cx,cz" -> instances. */
|
|
export function cellsOf(instances, size) {
|
|
const cells = new Map();
|
|
for (const instance of instances) {
|
|
const key = Math.floor(instance.x / size) + ',' + Math.floor(instance.z / size);
|
|
if (!cells.has(key)) cells.set(key, []);
|
|
cells.get(key).push(instance);
|
|
}
|
|
return cells;
|
|
}
|