Initial commit
commit
cdb6c25aae
@ -0,0 +1,5 @@
|
||||
dist
|
||||
node_modules
|
||||
public/blob/textures/*
|
||||
!public/blob/textures/.gitkeep
|
||||
public/blob/meshes.json
|
@ -0,0 +1,30 @@
|
||||
# Morrowind Three.JS
|
||||
|
||||
A project that takes morrowind's player heads and shows it on a webpage
|
||||
|
||||
![](examples/Z9x9jF6.png)
|
||||
![](examples/ZmJe6PQ.png)
|
||||
|
||||
## Requirements
|
||||
|
||||
- pyFFI
|
||||
- yarn / npm
|
||||
|
||||
# Setup
|
||||
|
||||
```bash
|
||||
cd [project]
|
||||
|
||||
# Copy textures
|
||||
cp [morrowind]/Data\ Files/Textures/TX_B_N_* ./public/blob/textures
|
||||
# Generate JSON meshes
|
||||
./script/export_nif.py [morrowind]/Data\ Files/Meshes/b > ./public/blob/meshes.json
|
||||
# Pack JS
|
||||
yarn
|
||||
yarn webpack
|
||||
# Run dev http server via PHP e.g.
|
||||
cd public;
|
||||
php -S 0:8888;
|
||||
```
|
||||
|
||||
meshes.json is not included because it's easily around 15MB
|
Binary file not shown.
After Width: | Height: | Size: 204 KiB |
Binary file not shown.
After Width: | Height: | Size: 268 KiB |
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "morrowinds-threejs",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"three": "^0.113.2",
|
||||
"webpack": "^4.41.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "^3.4.2",
|
||||
"file-loader": "^5.1.0",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"style-loader": "^1.1.3",
|
||||
"webpack-cli": "^3.3.11"
|
||||
}
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
margin: 25px;
|
||||
}
|
||||
|
||||
.wizard {
|
||||
position: absolute;
|
||||
padding: 25px;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay">
|
||||
<div class="part-1">
|
||||
<label for="part_1">Hair:</label>
|
||||
<select id="part_1">
|
||||
<option>Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="part-2">
|
||||
<label for="part_2">Head:</label>
|
||||
<select id="part_2">
|
||||
<option>Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard">
|
||||
<div class="race">
|
||||
<label for="race">Race: </label>
|
||||
<select name="race" id="race">
|
||||
<option>Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="gender">
|
||||
<label for="gender">Gender: </label>
|
||||
<select name="gender" id="gender">
|
||||
<option>Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hair">
|
||||
<button id="prev_hair"><</button>
|
||||
Hair
|
||||
<button id="next_hair">></button>
|
||||
</div>
|
||||
<div class="head">
|
||||
<button id="prev_head"><</button>
|
||||
Head
|
||||
<button id="next_head">></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/dist/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pyffi.formats.nif import NifFormat
|
||||
import json
|
||||
|
||||
|
||||
def export(path):
|
||||
data = NifFormat.Data()
|
||||
with open(path, 'rb') as f:
|
||||
data.inspect(f)
|
||||
data.read(f)
|
||||
|
||||
objects = []
|
||||
for root in data.roots:
|
||||
name = root.name.decode('utf-8').lower()
|
||||
# For this project, only hair and head matters
|
||||
if 'hair' in name or 'head' in name:
|
||||
sys.stderr.write("> " + name + "\n")
|
||||
else:
|
||||
sys.stderr.write(" " + name + "\n")
|
||||
continue
|
||||
|
||||
if isinstance(root, NifFormat.NiNode):
|
||||
objects.append(export_object(root))
|
||||
|
||||
# Some NiTriShape's are a root object
|
||||
if isinstance(root, NifFormat.NiTriShape):
|
||||
objects.append({'name': root.name.decode('utf-8'), 'shapes': [export_shape(root)]})
|
||||
|
||||
return objects
|
||||
|
||||
|
||||
def export_object(root: NifFormat.NiNode):
|
||||
obj = {'name': root.name.decode("utf-8"), 'shapes': []}
|
||||
|
||||
for shape in root.get_children():
|
||||
if not isinstance(shape, NifFormat.NiTriShape):
|
||||
continue
|
||||
|
||||
obj['shapes'].append(export_shape(shape))
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def export_shape(shape: NifFormat.NiTriShape):
|
||||
vertices = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.vertices)]
|
||||
faces = shape.data.get_triangles()
|
||||
uv_sets = [[[coord.u, coord.v] for coord in uv_set] for uv_set in shape.data.uv_sets]
|
||||
normals = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.normals)]
|
||||
texture = None
|
||||
|
||||
for property in shape.get_properties():
|
||||
if isinstance(property, NifFormat.NiTexturingProperty):
|
||||
texture = export_texture(property.base_texture)
|
||||
|
||||
return {
|
||||
'name': shape.name.decode("utf-8"),
|
||||
'vertices': vertices,
|
||||
'faces': faces,
|
||||
'uvSets': uv_sets,
|
||||
'normals': normals,
|
||||
'texture': texture,
|
||||
'translation': (shape.translation.x, shape.translation.y, shape.translation.z),
|
||||
'rotation': shape.rotation.as_list(),
|
||||
}
|
||||
|
||||
|
||||
def export_texture(texture):
|
||||
wrap_s = texture.clamp_mode in [2, 3]
|
||||
wrap_t = texture.clamp_mode in [1, 3]
|
||||
source = texture.source
|
||||
|
||||
return {
|
||||
'wrapS': wrap_s,
|
||||
'wrapT': wrap_t,
|
||||
'uvSet': texture.uv_set,
|
||||
'file': source.file_name.decode("utf-8")
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <dir>")
|
||||
exit(1)
|
||||
|
||||
objects = []
|
||||
for item in os.scandir(sys.argv[1]):
|
||||
if item.is_file():
|
||||
objects.extend(export(item.path))
|
||||
|
||||
print(json.dumps(objects))
|
@ -0,0 +1,66 @@
|
||||
import * as THREE from 'three'
|
||||
import {Color, Group} from 'three'
|
||||
import {OrbitControls} from "three/examples/jsm/controls/OrbitControls";
|
||||
|
||||
export class HeadRenderer {
|
||||
constructor() {
|
||||
this.shapes = {};
|
||||
this.scene = null;
|
||||
this.renderer = null;
|
||||
this.camera = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
const W = window.innerWidth, H = window.innerHeight;
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer();
|
||||
this.renderer.setSize(W, H);
|
||||
document.body.appendChild(this.renderer.domElement);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(70, W / H, 1, 2000);
|
||||
this.camera.position.z = 600;
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new Color(0xffffff);
|
||||
|
||||
let light = new THREE.PointLight(0xffffff, 1);
|
||||
light.position.set(1, 1, 2);
|
||||
|
||||
// Make sure light follow the camera
|
||||
this.camera.add(light);
|
||||
|
||||
// Add camera to scene so light gets rendered
|
||||
this.scene.add(this.camera);
|
||||
|
||||
let ambientLight = new THREE.AmbientLight(0x404040, 1);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
new OrbitControls(this.camera, this.renderer.domElement);
|
||||
|
||||
if (typeof __THREE_DEVTOOLS__ !== 'undefined') {
|
||||
__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent('observe', {detail: this.scene}));
|
||||
__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent('observe', {detail: this.renderer}));
|
||||
}
|
||||
}
|
||||
|
||||
draw() {
|
||||
window.requestAnimationFrame(() => this.draw());
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
add(shapeCollection) {
|
||||
this.shapes[shapeCollection.id] = shapeCollection;
|
||||
this.scene.add(shapeCollection.getGroup());
|
||||
}
|
||||
|
||||
remove(shapeCollection) {
|
||||
delete this.shapes[shapeCollection.id];
|
||||
this.scene.remove(shapeCollection.getGroup());
|
||||
}
|
||||
|
||||
expose() {
|
||||
window.scene = this.scene;
|
||||
window.camera = this.camera;
|
||||
window.renderer = this.renderer;
|
||||
}
|
||||
}
|
@ -0,0 +1,151 @@
|
||||
import {
|
||||
Face3,
|
||||
Geometry, Matrix4,
|
||||
Mesh, MeshPhongMaterial,
|
||||
RepeatWrapping,
|
||||
TextureLoader,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import {uuidv4} from "./util";
|
||||
import {TGALoader} from "three/examples/jsm/loaders/TGALoader";
|
||||
import {DDSLoader} from "three/examples/jsm/loaders/DDSLoader";
|
||||
|
||||
export class Shape {
|
||||
constructor({
|
||||
vertices = [],
|
||||
faces = [],
|
||||
texture = null,
|
||||
uvSets = [],
|
||||
normals = [],
|
||||
translation = [0, 0, 0],
|
||||
rotation
|
||||
} = {}, filePrefix = "") {
|
||||
this.id = uuidv4();
|
||||
|
||||
this.vertices = vertices;
|
||||
this.translation = translation;
|
||||
this.rotation = rotation;
|
||||
this.faces = faces;
|
||||
this.textureInfo = texture;
|
||||
this.normals = normals;
|
||||
this.uvSets = uvSets;
|
||||
|
||||
this.filePrefix = filePrefix;
|
||||
|
||||
this.geometry = null;
|
||||
this.mesh = null;
|
||||
this.texture = null;
|
||||
this.material = null;
|
||||
|
||||
}
|
||||
|
||||
getMaterial() {
|
||||
if (this.material) {
|
||||
return this.material;
|
||||
}
|
||||
|
||||
const params = {
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
alphaTest: 0.5,
|
||||
};
|
||||
if (this.textureInfo) {
|
||||
let Loader;
|
||||
|
||||
if (this.textureInfo.file.toLowerCase().endsWith('.tga')) {
|
||||
Loader = TGALoader
|
||||
} else if (this.textureInfo.file.toLowerCase().endsWith('.dds')) {
|
||||
Loader = DDSLoader
|
||||
} else {
|
||||
Loader = TextureLoader
|
||||
}
|
||||
|
||||
this.texture = new Loader().load(this.filePrefix + this.textureInfo.file, () => {
|
||||
}, null, (err) => {
|
||||
console.log(err)
|
||||
});
|
||||
|
||||
if (this.textureInfo.wrapS) {
|
||||
this.texture.wrapS = RepeatWrapping;
|
||||
}
|
||||
|
||||
if (this.textureInfo.wrapT) {
|
||||
this.texture.wrapT = RepeatWrapping;
|
||||
}
|
||||
|
||||
this.texture.flipY = false;
|
||||
params.map = this.texture;
|
||||
}
|
||||
|
||||
return this.material = new MeshPhongMaterial(params);
|
||||
}
|
||||
|
||||
getGeometry() {
|
||||
if (this.geometry) {
|
||||
return this.geometry;
|
||||
}
|
||||
|
||||
const geometry = this.geometry = new Geometry();
|
||||
|
||||
for (let [x, y, z] of this.vertices) {
|
||||
geometry.vertices.push(new Vector3(x, y, z))
|
||||
}
|
||||
|
||||
const uvSets = [];
|
||||
|
||||
for (let i = 0; i < this.uvSets.length; i++) {
|
||||
uvSets.push([]);
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.faces.length; i++) {
|
||||
let [a, b, c] = this.faces[i];
|
||||
|
||||
// Add faces with normals
|
||||
// Normals are stored per vector
|
||||
geometry.faces.push(new Face3(a, b, c, [
|
||||
new Vector3(this.normals[a][0], this.normals[a][1], this.normals[a][2]),
|
||||
new Vector3(this.normals[b][0], this.normals[b][1], this.normals[b][2]),
|
||||
new Vector3(this.normals[c][0], this.normals[c][1], this.normals[c][2])
|
||||
]));
|
||||
|
||||
for (let j = 0; j < this.uvSets.length; j++) {
|
||||
uvSets[j].push([
|
||||
new Vector2(this.uvSets[j][a][0], this.uvSets[j][a][1]),
|
||||
new Vector2(this.uvSets[j][b][0], this.uvSets[j][b][1]),
|
||||
new Vector2(this.uvSets[j][c][0], this.uvSets[j][c][1]),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
geometry.faceVertexUvs = uvSets;
|
||||
|
||||
// First rotate
|
||||
let m = new Matrix4();
|
||||
m.makeBasis(new Vector3(...this.rotation[0]), new Vector3(...this.rotation[1]), new Vector3(...this.rotation[2]));
|
||||
geometry.applyMatrix4(m);
|
||||
|
||||
// Then translate
|
||||
geometry.translate(this.translation[0], this.translation[1], this.translation[2]);
|
||||
|
||||
// Scale up a bit
|
||||
geometry.scale(20, 20, 20);
|
||||
|
||||
// Rotate the face so it faces us \o/
|
||||
geometry.rotateX(290 * (Math.PI / 180));
|
||||
|
||||
geometry.computeFaceNormals();
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
return this.geometry;
|
||||
}
|
||||
|
||||
getMesh() {
|
||||
if (this.mesh) {
|
||||
return this.mesh;
|
||||
}
|
||||
|
||||
this.mesh = new Mesh(this.getGeometry(), this.getMaterial());
|
||||
return this.mesh;
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import {Shape} from "./Shape";
|
||||
import {Group} from "three";
|
||||
import {uuidv4} from "./util";
|
||||
|
||||
export class ShapeCollection {
|
||||
constructor({shapes = [], name = ""}, filePrefix = "") {
|
||||
this.id = uuidv4();
|
||||
this.shapes = shapes.map((s) => new Shape(s, filePrefix));
|
||||
this.group = null;
|
||||
}
|
||||
|
||||
getGroup() {
|
||||
if (this.group) {
|
||||
return this.group;
|
||||
}
|
||||
|
||||
// Create group for all shapes
|
||||
this.group = new Group();
|
||||
|
||||
for (let shape of this.shapes) {
|
||||
this.group.add(shape.getMesh())
|
||||
}
|
||||
|
||||
return this.group
|
||||
}
|
||||
}
|
@ -0,0 +1,170 @@
|
||||
import {HeadRenderer} from "./HeadRenderer";
|
||||
import * as THREE from "three";
|
||||
import {ShapeCollection} from "./ShapeCollection";
|
||||
import {uuidv4} from "./util";
|
||||
|
||||
const headRenderer = window.headRenderer = new HeadRenderer();
|
||||
|
||||
const part1 = document.querySelector("#part_1");
|
||||
let shape1 = null;
|
||||
const part2 = document.querySelector("#part_2");
|
||||
let shape2 = null;
|
||||
|
||||
const race = document.querySelector("#race");
|
||||
const gender = document.querySelector("#gender");
|
||||
|
||||
const prevHair = document.querySelector('#prev_hair');
|
||||
const nextHair = document.querySelector('#next_hair');
|
||||
|
||||
const prevHead = document.querySelector('#prev_head');
|
||||
const nextHead = document.querySelector('#next_head');
|
||||
|
||||
|
||||
window.addEventListener("load", async () => {
|
||||
const allMeshes = await (await fetch("blob/meshes.json")).json();
|
||||
|
||||
part1.innerHTML = "";
|
||||
part2.innerHTML = "";
|
||||
const indexed = {};
|
||||
const tree = {};
|
||||
const shapeCache = {};
|
||||
|
||||
headRenderer.init();
|
||||
headRenderer.expose();
|
||||
|
||||
for (let mesh of allMeshes) {
|
||||
let [_, x, race, gender, part, nr = undefined] = mesh.name.split('_');
|
||||
race = race.replace(new RegExp("\\s+"), ' ');
|
||||
if (race.toLowerCase() === "khajiit") {
|
||||
console.log(part, mesh.name, nr);
|
||||
}
|
||||
|
||||
if (x !== 'N') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nr === undefined) {
|
||||
[_, part, nr] = part.match(/(head|hair)(\d+)?/i) || [];
|
||||
console.log(part);
|
||||
|
||||
if (!nr) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let option = document.createElement("option");
|
||||
option.textContent = mesh.name;
|
||||
option.value = mesh.id = uuidv4();
|
||||
indexed[mesh.id] = mesh;
|
||||
|
||||
|
||||
if (mesh.name.toLowerCase().indexOf("hair") !== -1) {
|
||||
part1.appendChild(option);
|
||||
}
|
||||
|
||||
if (mesh.name.toLowerCase().indexOf("head") !== -1) {
|
||||
part2.appendChild(option);
|
||||
}
|
||||
|
||||
if (!tree[race]) {
|
||||
tree[race] = {};
|
||||
}
|
||||
|
||||
if (!tree[race][gender]) {
|
||||
tree[race][gender] = {'head': [], 'hair': []}
|
||||
}
|
||||
|
||||
tree[race][gender][part.toLowerCase()].push(mesh.id);
|
||||
}
|
||||
|
||||
race.innerHTML = "";
|
||||
for (let raceX in tree) {
|
||||
let option = document.createElement("option");
|
||||
option.textContent = raceX;
|
||||
option.value = raceX;
|
||||
race.appendChild(option);
|
||||
}
|
||||
|
||||
race.addEventListener('change', () => {
|
||||
setRace(race.value);
|
||||
});
|
||||
|
||||
function setRace(name) {
|
||||
race.value = name;
|
||||
gender.innerHTML = "";
|
||||
for (let genderX in tree[name]) {
|
||||
let option = document.createElement("option");
|
||||
option.textContent = genderX;
|
||||
option.value = genderX;
|
||||
gender.appendChild(option);
|
||||
}
|
||||
|
||||
setGender(gender.value)
|
||||
}
|
||||
|
||||
gender.addEventListener("change", () => {
|
||||
setGender(gender.value);
|
||||
});
|
||||
|
||||
function setGender(name) {
|
||||
gender.value = name;
|
||||
setHair(tree[race.value][name]['hair'][0]);
|
||||
setHead(tree[race.value][name]['head'][0]);
|
||||
}
|
||||
|
||||
setRace("Dark Elf");
|
||||
setGender("M");
|
||||
|
||||
function getShape(name) {
|
||||
return new ShapeCollection(indexed[name], "blob/textures/");
|
||||
}
|
||||
|
||||
function setHair(id) {
|
||||
part1.value = id;
|
||||
if (shape1 !== null) {
|
||||
headRenderer.remove(shape1);
|
||||
}
|
||||
headRenderer.add(shape1 = getShape(id));
|
||||
}
|
||||
|
||||
part1.addEventListener("change", () => {
|
||||
setHair(part1.value);
|
||||
});
|
||||
|
||||
prevHair.addEventListener('click', () => {
|
||||
let hairList = tree[race.value][gender.value]['hair'];
|
||||
setHair(hairList[(hairList.length + (hairList.indexOf(part1.value) - 1)) % hairList.length]);
|
||||
});
|
||||
|
||||
nextHair.addEventListener('click', () => {
|
||||
let hairList = tree[race.value][gender.value]['hair'];
|
||||
setHair(hairList[(hairList.indexOf(part1.value) + 1) % hairList.length]);
|
||||
});
|
||||
|
||||
function setHead(id) {
|
||||
part2.value = id;
|
||||
if (shape2 !== null) {
|
||||
headRenderer.remove(shape2);
|
||||
}
|
||||
headRenderer.add(shape2 = getShape(id));
|
||||
}
|
||||
|
||||
part2.addEventListener("change", () => {
|
||||
setHead(part2.value);
|
||||
});
|
||||
|
||||
|
||||
prevHead.addEventListener('click', () => {
|
||||
let headList = tree[race.value][gender.value]['head'];
|
||||
setHead(headList[(headList.length + (headList.indexOf(part2.value) - 1)) % headList.length]);
|
||||
});
|
||||
|
||||
nextHead.addEventListener('click', () => {
|
||||
let headList = tree[race.value][gender.value]['head'];
|
||||
setHead(headList[(headList.indexOf(part2.value) + 1) % headList.length]);
|
||||
});
|
||||
|
||||
headRenderer.draw();
|
||||
});
|
||||
|
||||
window.THREE = THREE;
|
@ -0,0 +1,6 @@
|
||||
// I found this on the internet
|
||||
export function uuidv4() {
|
||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||
);
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.js',
|
||||
mode: 'development',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'public/js/dist'),
|
||||
filename: 'bundle.js'
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
use: ["source-map-loader"],
|
||||
enforce: "pre"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue