194 lines
8.0 KiB
JavaScript
194 lines
8.0 KiB
JavaScript
"use strict";
|
|
|
|
// Loading game meshes into a GL3D.View: the .msh, its material script (Material/<mesh>.txt, one
|
|
// material per sub-mesh), the textures (decoded here, uploaded per view) and model configs.
|
|
// Shared by the model viewer and the map editor; each keeps its own kit (one texture cache per
|
|
// WebGL context).
|
|
|
|
const MeshKit = (() => {
|
|
let seq = 0;
|
|
|
|
function create(view, getSrc, options = {}) {
|
|
const tex = new Map(); // texture name → Promise<{ tex, img, w, h, source } | { error }>
|
|
|
|
function texture(name) {
|
|
const key = name.toLowerCase();
|
|
if (!tex.has(key)) {
|
|
tex.set(
|
|
key,
|
|
(async () => {
|
|
const src = getSrc();
|
|
if (!src) return { error: "no source" };
|
|
const t = await call("mdl:texture", src, name);
|
|
if (!t) return { error: "not found" };
|
|
let img;
|
|
if (t.ext === "dds") img = TexDecode.dds(t.bytes);
|
|
else if (t.ext === "tga") img = TexDecode.tga(t.bytes);
|
|
else img = await createImageBitmap(new Blob([t.bytes]), { premultiplyAlpha: "none" });
|
|
const prepared = options.prepareTexture ? options.prepareTexture({ img, ext: t.ext, name }) : img;
|
|
return { tex: view.texture(prepared), img: prepared, w: prepared.width, h: prepared.height, source: t.source, ext: t.ext };
|
|
})().catch((err) => ({ error: err.message })),
|
|
);
|
|
}
|
|
return tex.get(key);
|
|
}
|
|
|
|
async function readNamed(name, folder, strict = false) {
|
|
const src = getSrc();
|
|
if (!src) return null;
|
|
return call("mdl:read", src, name, folder, strict);
|
|
}
|
|
|
|
/**
|
|
* scene_blend "<op> <src> <dst>" (D3D style: add / subtract / rev_subtract / min / max with
|
|
* zero, one, src_color, inv_src_color, src_alpha, inv_src_alpha, dest_alpha, inv_dest_alpha,
|
|
* dest_color, inv_dest_color) → { op, src, dst }, or null for none / opaque.
|
|
*/
|
|
function parseBlend(s) {
|
|
const t = (s || "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
if (!t.length) return null;
|
|
// ioMaterialSerializer::ParseSceneBlend delegates named modes to
|
|
// ioPass::SetSceneBlending. Keep its factors, especially additive alpha.
|
|
if (t.length === 1) {
|
|
const named = {
|
|
no_alpha: null,
|
|
src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
|
|
color_key: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
|
|
alpha_blend: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
|
|
add: { op: "add", src: "src_alpha", dst: "one" },
|
|
screen: { op: "add", src: "inv_dest_color", dst: "one" },
|
|
lighten: { op: "max", src: "one", dst: "one" },
|
|
darken: { op: "min", src: "one", dst: "one" },
|
|
rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" },
|
|
subtract: { op: "subtract", src: "src_alpha", dst: "one" },
|
|
};
|
|
if (Object.hasOwn(named, t[0])) return named[t[0]];
|
|
}
|
|
if (t[0] === "modulate") return { op: "add", src: "dest_color", dst: "zero" };
|
|
const [op, src, dst] = t.length >= 3 ? t : ["add", ...t];
|
|
if (src === "one" && dst === "zero") return null;
|
|
return { op, src: src.replace(/s$/, ""), dst: (dst || "zero").replace(/colors$/, "color") };
|
|
}
|
|
|
|
/** Texture, UV set, lightmap and blending of a sub-mesh from its material. */
|
|
function groupLook(d, data) {
|
|
const set = (n) => (n === 1 && data.uv1 ? 1 : 0);
|
|
const blendFunc = parseBlend(d?.blend);
|
|
return {
|
|
uvSet: set(d?.uvSet),
|
|
texName: d?.texture || null,
|
|
lmName: d?.lightmap || null,
|
|
lmSet: set(d?.lightmapSet),
|
|
lmAddDiffuse: !!d?.lmAddDiffuse,
|
|
diffuseColor: d?.diffuseColor || [1, 1, 1],
|
|
blend: !!blendFunc,
|
|
blendFunc,
|
|
texAnim: d?.anim || null,
|
|
lmAnim: d?.lightmapAnim || null,
|
|
alphaCut: d?.alphaReject ? 0.5 : 0,
|
|
tex: null,
|
|
lmTex: null,
|
|
texInfo: null,
|
|
};
|
|
}
|
|
|
|
/** Reads the material script, builds the draw groups and puts the mesh in the view. → m */
|
|
async function loadMesh(hit, name, place = {}) {
|
|
const data = LSF.parseMsh(hit.bytes);
|
|
const m = { id: ++seq, name, data, where: hit.where, source: hit.source, visible: true, dirty: false, mat: null, groups: [] };
|
|
const mh = await readNamed(`${name}.txt`, "material", true).catch(() => null);
|
|
if (mh) {
|
|
const text = LSF.decode(mh.bytes);
|
|
m.mat = { text, raw: mh.bytes, where: mh.where, source: mh.source, mats: LSF.parseMaterial(text), dirty: false };
|
|
}
|
|
const subs = data.subs.length ? data.subs : [{ indexStart: 0, faceCount: data.indices.length / 3 }];
|
|
m.groups = subs.map((sub, i) => {
|
|
const start = Math.min(sub.indexStart, data.indices.length);
|
|
return Object.assign({ start, count: Math.min(sub.faceCount * 3, data.indices.length - start) }, groupLook(LSF.diffuseOf(m.mat?.mats[i]), data));
|
|
});
|
|
if (data.boneNames?.length) {
|
|
m.skinPos = new Float32Array(data.pos.length);
|
|
m.skinNrm = data.normal ? new Float32Array(data.normal.length) : null;
|
|
}
|
|
view.addMesh(m.id, { pos: data.pos, normal: data.normal, uv0: data.uv0, uv1: data.uv1, indices: data.indices, groups: m.groups }, place);
|
|
return m;
|
|
}
|
|
|
|
async function loadTextures(m) {
|
|
await Promise.all(
|
|
m.groups.map(async (g, gi) => {
|
|
if (g.texName) {
|
|
const t = await texture(g.texName);
|
|
g.texInfo = t;
|
|
if (t.tex) view.setGroupTexture(m.id, gi, t.tex);
|
|
// anim_texture N: frames <name>1.dds … <name>(N-1).dds after the base one
|
|
const n = g.texAnim?.frames?.count || 0;
|
|
if (n > 1) {
|
|
const dot = g.texName.lastIndexOf(".");
|
|
const stem = dot > 0 ? g.texName.slice(0, dot) : g.texName;
|
|
const ext = dot > 0 ? g.texName.slice(dot) : "";
|
|
const frames = [t.tex || null];
|
|
for (let i = 1; i < n; i++) frames.push((await texture(`${stem}${i}${ext}`)).tex || null);
|
|
g.frameTex = frames;
|
|
}
|
|
}
|
|
if (g.lmName) {
|
|
const t = await texture(g.lmName);
|
|
if (t.tex) view.setGroupTexture(m.id, gi, t.tex, "lmTex");
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Re-reads a mesh's draw groups after its material text changed. */
|
|
function applyMaterial(m) {
|
|
m.groups.forEach((g, i) => {
|
|
Object.assign(g, groupLook(LSF.diffuseOf(m.mat.mats[i]), m.data));
|
|
view.setGroupTexture(m.id, i, null);
|
|
view.setGroupTexture(m.id, i, null, "lmTex");
|
|
});
|
|
return loadTextures(m);
|
|
}
|
|
|
|
/** A model config (resource/model/<path>) by its path, e.g. "staticmodel/prisoner.txt". */
|
|
async function readModel(path) {
|
|
const src = getSrc();
|
|
if (!src) return null;
|
|
const hit = await call("mdl:readPath", src, `model/${path.replace(/\\/g, "/").replace(/^\/+/, "")}`);
|
|
return hit ? { hit, cfg: LSF.parseModelCfg(LSF.decode(hit.bytes)) } : null;
|
|
}
|
|
|
|
/** Texture animations of a mesh at time t (s); t = null puts them back to rest. → true when something animates */
|
|
function animate(m, t) {
|
|
let any = false;
|
|
for (const g of m.groups) {
|
|
if (g.texAnim) {
|
|
any = true;
|
|
if (t === null) {
|
|
g.texM = null;
|
|
g.colorMul = 1;
|
|
g.emissive = 0;
|
|
if (g.frameTex?.[0]) g.tex = g.frameTex[0];
|
|
} else {
|
|
const r = LSF.texAnimAt(g.texAnim, t);
|
|
g.texM = r.m;
|
|
g.colorMul = r.colorMul;
|
|
g.emissive = r.emissive;
|
|
if (g.frameTex?.[r.frame]) g.tex = g.frameTex[r.frame];
|
|
}
|
|
}
|
|
if (g.lmAnim) {
|
|
any = true;
|
|
g.lmM = t === null ? null : LSF.texAnimAt(g.lmAnim, t).m;
|
|
}
|
|
}
|
|
return any;
|
|
}
|
|
|
|
return { texture, readNamed, groupLook, loadMesh, loadTextures, applyMaterial, readModel, animate, clearTextures: () => tex.clear() };
|
|
}
|
|
|
|
return { create };
|
|
})();
|
|
window.MeshKit = MeshKit;
|