SA Model Viewer base

This commit is contained in:
jack
2026-08-29 20:31:31 +01:00
commit 4eab14d4d6
20 changed files with 57452 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
import * as THREE from 'three';
import { OrbitControls } from '/vendor/OrbitControls.js';
const MISSING_COLOR = 0xb0b0b0;
export class Viewer {
constructor(canvas) {
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x171a1f);
this.camera = new THREE.PerspectiveCamera(45, 1, 0.01, 2000);
this.camera.position.set(1.5, 1.2, 2.5);
this.controls = new OrbitControls(this.camera, canvas);
this.controls.enableDamping = true;
this.scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const key = new THREE.DirectionalLight(0xffffff, 1.6);
key.position.set(3, 6, 4);
this.scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.7);
fill.position.set(-4, 2, -3);
this.scene.add(fill);
this.model = null;
this.textures = [];
window.addEventListener('resize', () => this.resize());
this.resize();
this.renderer.setAnimationLoop(() => {
this.controls.update();
this.renderer.render(this.scene, this.camera);
});
}
resize() {
const canvas = this.renderer.domElement;
const width = canvas.clientWidth || 1;
const height = canvas.clientHeight || 1;
this.renderer.setSize(width, height, false);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
}
clear() {
if (this.model) {
this.model.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) [].concat(obj.material).forEach((m) => m.dispose());
});
this.scene.remove(this.model);
this.model = null;
}
this.textures.forEach((t) => t.dispose());
this.textures = [];
}
makeTexture(source) {
const texture = new THREE.DataTexture(source.rgba, source.width, source.height, THREE.RGBAFormat);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.generateMipmaps = true;
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
// RenderWare UVs put (0,0) at the top left, which matches the raw row order.
texture.flipY = false;
texture.needsUpdate = true;
this.textures.push(texture);
return texture;
}
buildMaterial(material, txd, report) {
const params = {
color: new THREE.Color(material.color[0], material.color[1], material.color[2]),
side: THREE.DoubleSide,
shininess: 8,
};
if (material.texture && material.texture.name) {
const key = material.texture.name.toLowerCase();
const source = txd && txd.textures.get(key);
if (source) {
params.map = this.makeTexture(source);
params.color = new THREE.Color(0xffffff);
if (source.hasAlpha) {
params.transparent = true;
params.alphaTest = 0.35;
params.depthWrite = true;
}
} else {
report.missingTextures.add(material.texture.name);
params.color = new THREE.Color(MISSING_COLOR);
}
}
if (material.color[3] < 1) {
params.transparent = true;
params.opacity = material.color[3];
}
return new THREE.MeshPhongMaterial(params);
}
buildGeometry(geo) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(geo.positions, 3));
if (geo.uvs) geometry.setAttribute('uv', new THREE.BufferAttribute(geo.uvs, 2));
if (geo.colors) geometry.setAttribute('color', new THREE.BufferAttribute(geo.colors, 3));
geometry.setIndex(new THREE.BufferAttribute(geo.indices, 1));
geo.groups.forEach((g) => geometry.addGroup(g.start, g.count, g.material));
if (geo.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(geo.normals, 3));
else geometry.computeVertexNormals();
return geometry;
}
// Frames form a tree; an atomic is placed by its frame's world transform.
// Skinned geometry is the exception: its vertices are stored in the space of
// the atomic frame's *parent*, so that frame's own transform is skipped.
worldMatrix(frames, index, skipSelf) {
const matrix = new THREE.Matrix4();
const chain = [];
let i = index;
let guard = 0;
while (i >= 0 && i < frames.length && guard++ < frames.length) {
chain.unshift(frames[i]);
i = frames[i].parent;
}
if (skipSelf) chain.pop();
for (const frame of chain) {
matrix.multiply(new THREE.Matrix4().fromArray(frame.matrix));
}
return matrix;
}
load(dff, txd) {
this.clear();
const report = { missingTextures: new Set(), meshes: 0, triangles: 0, vertices: 0 };
const root = new THREE.Object3D();
// RenderWare is Z up; three.js is Y up.
root.rotation.x = -Math.PI / 2;
const atomics = dff.atomics.length
? dff.atomics
: dff.geometries.map((_, i) => ({ geometry: i, frame: -1 }));
for (const atomic of atomics) {
const geo = dff.geometries[atomic.geometry];
if (!geo) continue;
const mesh = new THREE.Mesh(
this.buildGeometry(geo),
geo.materials.length
? geo.materials.map((m) => this.buildMaterial(m, txd, report))
: new THREE.MeshPhongMaterial({ color: MISSING_COLOR, side: THREE.DoubleSide })
);
mesh.applyMatrix4(this.worldMatrix(dff.frames, atomic.frame, geo.skinned));
mesh.name = (dff.frames[atomic.frame] && dff.frames[atomic.frame].name) || 'atomic' + report.meshes;
root.add(mesh);
report.meshes++;
report.vertices += geo.numVertices;
report.triangles += geo.indices.length / 3;
}
this.scene.add(root);
this.model = root;
this.frame();
return report;
}
frame() {
if (!this.model) return;
const box = new THREE.Box3().setFromObject(this.model);
if (box.isEmpty()) return;
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(size.x, size.y, size.z) * 0.5 || 1;
this.controls.target.copy(center);
this.camera.position.copy(center).add(new THREE.Vector3(radius * 1.4, radius * 0.8, radius * 2.2));
this.camera.near = radius / 100;
this.camera.far = radius * 100;
this.camera.updateProjectionMatrix();
this.controls.update();
}
}