Files
2026-08-31 00:14:52 +01:00

296 lines
9.3 KiB
JavaScript

// DFF (RenderWare Clump) reader.
//
// Layout: Clump -> FrameList -> GeometryList -> Geometry* -> MaterialList
// -> Atomic* (pairs a geometry with a frame)
//
// Only the parts a viewer needs are read: transforms, vertex data, triangles
// and material/texture names. Skinning, collision and 2dfx are skipped.
import { RW, Chunk, Reader, versionString } from './stream.js';
const FLAG_TRISTRIP = 0x0001;
const FLAG_TEXTURED = 0x0004;
const FLAG_PRELIT = 0x0008;
const FLAG_NORMALS = 0x0010;
const FLAG_TEXTURED2 = 0x0080;
const FLAG_NATIVE = 0x01000000;
function parseFrameList(chunk) {
const r = chunk.structReader();
const count = r.i32();
const frames = [];
for (let i = 0; i < count; i++) {
// 3x3 basis (right, up, at) then position - already column major for three.js.
const m = r.f32Array(12);
const parent = r.i32();
r.u32(); // matrix flags, unused
frames.push({
parent,
name: '',
matrix: [m[0], m[1], m[2], 0, m[3], m[4], m[5], 0, m[6], m[7], m[8], 0, m[9], m[10], m[11], 1],
});
}
// Each frame's extension follows the struct, in the same order.
let i = 0;
for (const ext of chunk.findAll(RW.EXTENSION)) {
if (i >= frames.length) break;
const nameChunk = ext.find(RW.FRAME_NAME);
if (nameChunk) frames[i].name = nameChunk.reader().fixedString(nameChunk.size);
i++;
}
return frames;
}
function parseTexture(chunk) {
const r = chunk.structReader();
r.u16(); // filter mode
r.u16(); // uv addressing
const strings = chunk.findAll(RW.STRING);
const read = (c) => (c ? c.reader().fixedString(c.size) : '');
return { name: read(strings[0]), mask: read(strings[1]) };
}
function parseMaterial(chunk) {
const r = chunk.structReader();
r.i32(); // flags
const color = [r.u8() / 255, r.u8() / 255, r.u8() / 255, r.u8() / 255];
r.i32(); // unused
const isTextured = r.i32();
let ambient = 1, specular = 1, diffuse = 1;
if (chunk.version > 0x30400) {
ambient = r.f32();
specular = r.f32();
diffuse = r.f32();
}
const texChunk = chunk.find(RW.TEXTURE);
const texture = texChunk ? parseTexture(texChunk) : null;
return { color, isTextured: !!isTextured, ambient, specular, diffuse, texture };
}
function parseMaterialList(chunk) {
const r = chunk.structReader();
const count = r.i32();
const refs = [];
for (let i = 0; i < count; i++) refs.push(r.i32());
const chunks = chunk.findAll(RW.MATERIAL);
const materials = [];
let next = 0;
for (let i = 0; i < count; i++) {
// -1 means "a new material follows", anything else reuses an earlier one.
if (refs[i] >= 0 && materials[refs[i]]) materials.push(materials[refs[i]]);
else if (next < chunks.length) materials.push(parseMaterial(chunks[next++]));
else materials.push(parseMaterial(chunks[chunks.length - 1]));
}
return materials;
}
function parseGeometry(chunk) {
const r = chunk.structReader();
const format = r.u32();
const numTriangles = r.i32();
const numVertices = r.i32();
const numMorphTargets = r.i32();
if (chunk.version < 0x34000) {
r.f32(); r.f32(); r.f32(); // surface properties moved into the material later
}
if (format & FLAG_NATIVE) {
throw new Error('native (PS2/Xbox) geometry is not supported - use a PC dff');
}
let uvSets = (format & 0x00ff0000) >> 16;
if (!uvSets) uvSets = format & FLAG_TEXTURED2 ? 2 : format & FLAG_TEXTURED ? 1 : 0;
let colors = null;
if (format & FLAG_PRELIT) {
colors = new Float32Array(numVertices * 3);
for (let i = 0; i < numVertices; i++) {
colors[i * 3] = r.u8() / 255;
colors[i * 3 + 1] = r.u8() / 255;
colors[i * 3 + 2] = r.u8() / 255;
r.u8(); // prelit alpha, unused
}
}
let uvs = null;
for (let set = 0; set < uvSets; set++) {
const data = r.f32Array(numVertices * 2);
if (set === 0) uvs = data;
}
// Stored as (b, a, materialId, c) - the second vertex comes first and the
// material index sits between the vertices.
const tris = new Uint16Array(numTriangles * 3);
const triMaterial = new Uint16Array(numTriangles);
for (let i = 0; i < numTriangles; i++) {
const b = r.u16();
const a = r.u16();
triMaterial[i] = r.u16();
const c = r.u16();
tris[i * 3] = a;
tris[i * 3 + 1] = b;
tris[i * 3 + 2] = c;
}
let positions = null;
let normals = null;
for (let i = 0; i < numMorphTargets; i++) {
r.f32Array(4); // bounding sphere
const hasVertices = r.u32();
const hasNormals = r.u32();
const verts = hasVertices ? r.f32Array(numVertices * 3) : null;
const norms = hasNormals ? r.f32Array(numVertices * 3) : null;
if (i === 0) {
positions = verts;
normals = norms;
}
}
if (!positions) throw new Error('geometry has no vertex positions');
const matList = chunk.find(RW.MATLIST);
const materials = matList ? parseMaterialList(matList) : [];
// A skinned geometry stores its vertices in bind pose rather than in its
// frame's local space, so the viewer must not apply the frame transform.
const extension = chunk.find(RW.EXTENSION);
const skinned = !!(extension && extension.find(RW.SKIN));
// One draw group per material, so each group can get its own texture.
const order = [...Array(numTriangles).keys()].sort((x, y) => triMaterial[x] - triMaterial[y]);
let indices = new Uint32Array(numTriangles * 3);
let groups = [];
let current = null;
order.forEach((tri, slot) => {
indices[slot * 3] = tris[tri * 3];
indices[slot * 3 + 1] = tris[tri * 3 + 1];
indices[slot * 3 + 2] = tris[tri * 3 + 2];
const material = triMaterial[tri];
if (!current || current.material !== material) {
current = { start: slot * 3, count: 0, material };
groups.push(current);
}
current.count += 3;
});
// Many GTA:SA exporters leave the per-triangle material index at zero and put
// the real split in the BINMESH extension instead. The struct then yields a
// single group and the whole model renders with material 0's texture, so
// prefer BINMESH whenever it describes more submeshes than the struct did.
const binMesh = extension && extension.find(RW.BINMESH);
if (binMesh) {
const split = parseBinMesh(binMesh, numVertices);
if (split && split.groups.length > groups.length) {
indices = split.indices;
groups = split.groups;
}
}
return {
numVertices,
positions,
normals,
uvs,
colors,
indices,
groups,
materials,
skinned,
tristrip: !!(format & FLAG_TRISTRIP),
};
}
// The BINMESH extension holds the render-ready index lists, one submesh per
// material. flags bit 0 means the indices are tristrips rather than triangle
// lists; degenerate triangles are the standard way strips stitch together, so
// they are dropped rather than emitted as zero area faces.
function parseBinMesh(chunk, numVertices) {
const r = chunk.reader();
if (r.remaining < 12) return null;
const strip = r.u32() & 1;
const numMeshes = r.u32();
r.u32(); // total index count, recomputed below
const out = [];
const groups = [];
for (let i = 0; i < numMeshes; i++) {
if (r.remaining < 8) return null;
const count = r.u32();
const material = r.u32();
if (r.remaining < count * 4) return null;
const list = new Uint32Array(count);
for (let k = 0; k < count; k++) list[k] = r.u32();
const start = out.length;
if (strip) {
for (let k = 0; k + 2 < count; k++) {
const a = list[k], b = list[k + (k & 1 ? 2 : 1)], c = list[k + (k & 1 ? 1 : 2)];
if (a === b || b === c || a === c) continue; // strip stitch
out.push(a, b, c);
}
} else {
for (let k = 0; k + 2 < count; k += 3) out.push(list[k], list[k + 1], list[k + 2]);
}
if (out.length > start) groups.push({ start, count: out.length - start, material });
}
if (!groups.length) return null;
for (const index of out) {
if (index >= numVertices) return null; // not indices we can trust
}
return { indices: Uint32Array.from(out), groups };
}
export function parseDFF(buffer) {
const view = new DataView(buffer);
let clump = new Chunk(view, 0);
if (clump.type !== RW.CLUMP) {
// Some files carry a UV animation dictionary or similar before the clump.
clump = null;
for (let offset = 0; offset + 12 <= view.byteLength; ) {
const c = new Chunk(view, offset);
if (c.type === RW.CLUMP) { clump = c; break; }
if (c.size <= 0) break;
offset += 12 + c.size;
}
if (!clump) throw new Error('no clump chunk found - this is not a dff');
}
const r = clump.structReader();
const numAtomics = r.i32();
if (clump.version > 0x33000) {
r.i32(); // lights
r.i32(); // cameras
}
const frameList = clump.find(RW.FRAMELIST);
const frames = frameList ? parseFrameList(frameList) : [];
const geometryList = clump.find(RW.GEOMETRYLIST);
const geometries = [];
const errors = [];
if (geometryList) {
geometryList.findAll(RW.GEOMETRY).forEach((geo, i) => {
try {
geometries.push(parseGeometry(geo));
} catch (err) {
errors.push(`geometry ${i}: ${err.message}`);
geometries.push(null);
}
});
}
const atomics = clump.findAll(RW.ATOMIC).map((atomic) => {
const a = atomic.structReader();
return { frame: a.i32(), geometry: a.i32(), flags: a.u32() };
});
return {
version: clump.version,
versionText: versionString(clump.version),
numAtomics,
frames,
geometries,
atomics,
errors,
};
}