vendored meshoptimizer 1.2(submodule 环境故障改直接 vendor)。Mesh 多 LOD:各级索引连续拼进同一 index buffer(共享顶点,LodRange 区间 选择),解析期 meshopt_simplify 生成链(LOD0=原始,每级目标减半, 相对误差 1e-2,小网格复用上级);都在 worker 线程,加载零额外卡顿。 SceneRenderer 按 (mesh, LOD) 聚合 batch(lookup key = 指针<<8|lod): 主视图按屏占比近似 radius/dist 选级(阈值 10/20 倍半径);阴影级联 按级联序降级(越远越简)。Draw 按 batch.lod 选索引区间。 A/B 对比(100 背包 10×10,RTX 5060 Laptop,vsync off): - 纯背包:LOD 开 93-95 fps / 关 90-107 fps —— 无显著差异 - 加 SunTemple:LOD 开 52-56 / 关 51-55 —— 无显著差异 结论:当前场景瓶颈不在顶点数(5060 顶点吞吐富余),LOD 基础设施 保留(几何复杂资产/低端 GPU/更大场景生效),帧率问题转向 Tracy GPU 时间线定位真实瓶颈。 坑:LodRange 记录"LOD 不足复用最后一级"时 first 不得继续累加 (buffer 无重复数据,累加必越界——VUID-08798 抓的)。
816 lines
26 KiB
HTML
816 lines
26 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<title>meshoptimizer - demo</title>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
|
|
<style>
|
|
body {
|
|
font-family: Monospace;
|
|
background-color: #300a24;
|
|
color: #fff;
|
|
margin: 0px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
}
|
|
#container {
|
|
width: 100%;
|
|
flex: 1;
|
|
position: relative;
|
|
}
|
|
.renderer-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.renderer-container canvas {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
display: block;
|
|
user-select: none;
|
|
}
|
|
.renderer-label {
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 10px;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
padding: 5px 10px;
|
|
border-radius: 5px;
|
|
pointer-events: none;
|
|
font-size: 14px;
|
|
}
|
|
.stats-label {
|
|
position: absolute;
|
|
top: 45px;
|
|
left: 10px;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
padding: 5px 10px;
|
|
border-radius: 5px;
|
|
pointer-events: none;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
}
|
|
#progress-label {
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 1;
|
|
}
|
|
#texture-view,
|
|
#normal-view,
|
|
#material-view {
|
|
position: fixed;
|
|
left: 10px;
|
|
bottom: 10px;
|
|
width: 256px;
|
|
height: 256px;
|
|
display: none;
|
|
}
|
|
#normal-view {
|
|
left: 276px;
|
|
}
|
|
#material-view {
|
|
left: 542px;
|
|
}
|
|
</style>
|
|
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
|
|
"three-examples/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/",
|
|
"three-mesh-bvh": "https://cdn.jsdelivr.net/npm/three-mesh-bvh@0.9.10/build/index.module.js",
|
|
"watlas": "https://cdn.jsdelivr.net/npm/watlas@1.0.1/dist/watlas.js",
|
|
"lil-gui": "https://cdn.jsdelivr.net/npm/lil-gui@0.20.0/dist/lil-gui.esm.js"
|
|
}
|
|
}
|
|
</script>
|
|
</head>
|
|
|
|
<body>
|
|
<div id="progress-label" class="renderer-label" hidden></div>
|
|
<div id="container"></div>
|
|
<canvas id="texture-view"></canvas>
|
|
<canvas id="normal-view"></canvas>
|
|
<canvas id="material-view"></canvas>
|
|
<input type="file" id="fileInput" style="display: none" accept=".obj,.glb" />
|
|
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { GLTFLoader } from 'three-examples/loaders/GLTFLoader.js';
|
|
import { OBJLoader } from 'three-examples/loaders/OBJLoader.js';
|
|
import { GLTFExporter } from 'three-examples/exporters/GLTFExporter.js';
|
|
import { OrbitControls } from 'three-examples/controls/OrbitControls.js';
|
|
import { mergeGeometries, mergeVertices } from 'three-examples/utils/BufferGeometryUtils.js';
|
|
import { MeshoptDecoder } from '../js/meshopt_decoder.mjs';
|
|
import { MeshoptSimplifier } from '../js/meshopt_simplifier.js';
|
|
import { MeshoptTangents } from '../js/meshopt_tangents.js';
|
|
import * as repaint from './repaint.js';
|
|
import { Initialize as initializeWatlas, Atlas } from 'watlas';
|
|
import { GUI } from 'lil-gui';
|
|
|
|
var container;
|
|
var camera, renderer, scene, controls, model, merged, remeshed, remeshMaterial, wireframeMaterial, stats;
|
|
var paintCache = null;
|
|
var progressLabel = document.getElementById('progress-label');
|
|
|
|
var settings = {
|
|
wireframe: false,
|
|
wireframeOverlay: false,
|
|
resolution: 100,
|
|
thicken: false,
|
|
shell: false,
|
|
solve: true,
|
|
debug: false,
|
|
normalCrease: 60,
|
|
normalSmoothing: 1.5,
|
|
simplify: {
|
|
enabled: true,
|
|
regularize: true,
|
|
solve: true,
|
|
errorAuto: false,
|
|
errorThresholdLog10: 2.5,
|
|
forceTriangleCount: 0,
|
|
},
|
|
paint: {
|
|
mode: 'texture',
|
|
resolution: 1024,
|
|
gutter: 1,
|
|
atlasMode: 'balanced',
|
|
preserveNormalSeams: true,
|
|
infill: true,
|
|
normals: false,
|
|
materials: false,
|
|
},
|
|
|
|
loadFile: function () {
|
|
var input = document.getElementById('fileInput');
|
|
input.onchange = function () {
|
|
var file = input.files[0];
|
|
var uri = URL.createObjectURL(file);
|
|
var ext = file.name.split('.').pop().toLowerCase();
|
|
|
|
container.querySelector('.renderer-label').textContent = file.name;
|
|
load(uri, ext);
|
|
};
|
|
input.click();
|
|
},
|
|
|
|
remesh: function () {
|
|
remesh();
|
|
},
|
|
|
|
revert: function () {
|
|
revert();
|
|
},
|
|
|
|
exportFile: function () {
|
|
if (!remeshed) return;
|
|
exportGlb('remesh.glb');
|
|
},
|
|
|
|
updateModule: function () {
|
|
reload();
|
|
remesh();
|
|
},
|
|
autoUpdate: false,
|
|
autoUpdateStatus: '',
|
|
};
|
|
|
|
var gui = new GUI({ width: 300 });
|
|
gui.add(settings, 'remesh');
|
|
gui.add(settings, 'revert');
|
|
gui.add(settings, 'loadFile');
|
|
gui.add(settings, 'exportFile');
|
|
|
|
var guiDisplay = gui.addFolder('Display');
|
|
guiDisplay.add(settings, 'wireframe').onChange(update);
|
|
guiDisplay.add(settings, 'wireframeOverlay').onChange(update);
|
|
|
|
var guiRemesh = gui.addFolder('Remesh');
|
|
guiRemesh.add(settings, 'resolution', 4, 256, 1);
|
|
guiRemesh.add(settings, 'thicken').disable();
|
|
guiRemesh.add(settings, 'shell');
|
|
guiRemesh.add(settings, 'solve');
|
|
guiRemesh.add(settings, 'debug');
|
|
guiRemesh.add(settings, 'normalCrease', 0, 180, 1).name('normal crease');
|
|
guiRemesh.add(settings, 'normalSmoothing', 0, 10, 0.1).name('normal smoothing');
|
|
|
|
var guiSimplify = gui.addFolder('Simplify');
|
|
guiSimplify.add(settings.simplify, 'enabled');
|
|
guiSimplify.add(settings.simplify, 'regularize');
|
|
guiSimplify.add(settings.simplify, 'solve');
|
|
guiSimplify.add(settings.simplify, 'errorAuto');
|
|
guiSimplify.add(settings.simplify, 'errorThresholdLog10', 1.5, 3, 0.1);
|
|
guiSimplify.add(settings.simplify, 'forceTriangleCount');
|
|
|
|
var guiPaint = gui.addFolder('Paint');
|
|
guiPaint.add(settings.paint, 'mode', ['none', 'vertex', 'facets', 'texture']);
|
|
guiPaint.add(settings.paint, 'resolution', [256, 512, 1024, 2048]);
|
|
guiPaint.add(settings.paint, 'gutter', 0, 8, 1);
|
|
guiPaint.add(settings.paint, 'atlasMode', ['fast', 'balanced', 'quality']);
|
|
guiPaint.add(settings.paint, 'preserveNormalSeams');
|
|
guiPaint.add(settings.paint, 'infill');
|
|
guiPaint.add(settings.paint, 'normals').name('normal map');
|
|
guiPaint.add(settings.paint, 'materials').name('material map');
|
|
|
|
var guiUpdate = gui.addFolder('Update');
|
|
guiUpdate.add(settings, 'updateModule');
|
|
guiUpdate.add(settings, 'autoUpdate').onChange(autoReload);
|
|
guiUpdate.add(settings, 'autoUpdateStatus').listen();
|
|
|
|
init();
|
|
loadDefault();
|
|
render();
|
|
|
|
function createView(name) {
|
|
// Create renderer container
|
|
var rendererContainer = document.createElement('div');
|
|
rendererContainer.className = 'renderer-container';
|
|
container.appendChild(rendererContainer);
|
|
|
|
// Add label with file name
|
|
var label = document.createElement('div');
|
|
label.className = 'renderer-label';
|
|
label.textContent = name;
|
|
rendererContainer.appendChild(label);
|
|
|
|
// Add stats label
|
|
var statsLabel = document.createElement('div');
|
|
statsLabel.className = 'stats-label';
|
|
rendererContainer.appendChild(statsLabel);
|
|
|
|
// Initialize stats
|
|
stats = {
|
|
triangles: 0,
|
|
vertices: 0,
|
|
ratio: 0,
|
|
label: statsLabel,
|
|
};
|
|
|
|
// Create renderer
|
|
renderer = new THREE.WebGLRenderer();
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
rendererContainer.appendChild(renderer.domElement);
|
|
|
|
// Create scene
|
|
scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x300a24);
|
|
|
|
// Add lighting
|
|
var ambientLight = new THREE.AmbientLight(0xcccccc, 1);
|
|
scene.add(ambientLight);
|
|
|
|
var pointLightR = new THREE.PointLight(0xffffff, 4);
|
|
pointLightR.position.set(3, 3, 0);
|
|
pointLightR.decay = 0.5;
|
|
scene.add(pointLightR);
|
|
|
|
var pointLightL = new THREE.PointLight(0xffffff, 1);
|
|
pointLightL.position.set(-3, 5, 0);
|
|
pointLightL.decay = 0.5;
|
|
scene.add(pointLightL);
|
|
|
|
remeshMaterial = new THREE.MeshStandardMaterial({ color: 0xdddddd, polygonOffsetFactor: 0.5, polygonOffsetUnits: 16 });
|
|
wireframeMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, visible: false });
|
|
|
|
// Create controls
|
|
controls = new OrbitControls(camera, renderer.domElement);
|
|
|
|
// Adjust renderer size
|
|
updateRendererSizes();
|
|
}
|
|
|
|
function copyAttribute(attribute, count, itemSize, def) {
|
|
var result = new Float32Array(count * itemSize).fill(def);
|
|
|
|
if (attribute) {
|
|
var copy = Math.min(itemSize, attribute.itemSize);
|
|
for (var i = 0; i < count; ++i) {
|
|
for (var k = 0; k < copy; ++k) {
|
|
result[i * itemSize + k] = attribute.getComponent(i, k);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new THREE.BufferAttribute(result, itemSize);
|
|
}
|
|
|
|
function skinAttribute(object, attribute, w) {
|
|
var v = new THREE.Vector4();
|
|
|
|
for (var i = 0; i < attribute.count; ++i) {
|
|
v.set(attribute.getX(i), attribute.getY(i), attribute.getZ(i), w);
|
|
object.applyBoneTransform(i, v);
|
|
if (w == 0) v.normalize();
|
|
attribute.setXYZ(i, v.x, v.y, v.z);
|
|
}
|
|
}
|
|
|
|
function mergeMesh(model) {
|
|
var geometries = [];
|
|
var materials = [];
|
|
|
|
model.updateMatrixWorld(true);
|
|
model.traverse(function (object) {
|
|
if (object.isMesh && object.geometry.attributes.position) {
|
|
var source = object.geometry.attributes.position;
|
|
var geometry = new THREE.BufferGeometry();
|
|
|
|
geometry.setAttribute('position', copyAttribute(source, source.count, 3, 0));
|
|
|
|
// we need original normal/uv/colors for gpu repaint
|
|
// for mergeGeometries to agree to merge everything, we maintain consistent attribute sets
|
|
geometry.setAttribute('normal', copyAttribute(object.geometry.attributes.normal, source.count, 3, 0));
|
|
geometry.setAttribute('uv', copyAttribute(object.geometry.attributes.uv, source.count, 2, 0));
|
|
geometry.setAttribute('color', copyAttribute(object.geometry.attributes.color, source.count, 4, 1));
|
|
|
|
if (object.isSkinnedMesh) {
|
|
skinAttribute(object, geometry.attributes.position, 1);
|
|
skinAttribute(object, geometry.attributes.normal, 0);
|
|
}
|
|
|
|
var material = Array.isArray(object.material) ? object.material[0] : object.material;
|
|
if (materials.indexOf(material) < 0) materials.push(material);
|
|
|
|
var materialIndex = new Uint32Array(source.count).fill(materials.indexOf(material));
|
|
geometry.setAttribute('material', new THREE.BufferAttribute(materialIndex, 1));
|
|
|
|
if (object.geometry.index) {
|
|
geometry.setIndex(object.geometry.index);
|
|
} else {
|
|
var indices = new Uint32Array(source.count);
|
|
for (var i = 0; i < source.count; ++i) indices[i] = i;
|
|
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
}
|
|
|
|
// we prefer to use the original tangents, but we need to generate them if not present so that normal baking works
|
|
if (object.geometry.attributes.tangent) {
|
|
geometry.setAttribute('tangent', copyAttribute(object.geometry.attributes.tangent, source.count, 4, 0));
|
|
if (object.isSkinnedMesh) skinAttribute(object, geometry.attributes.tangent, 0);
|
|
} else {
|
|
geometry = generateTangents(geometry, -1);
|
|
geometry = mergeVertices(geometry);
|
|
}
|
|
|
|
geometry.applyMatrix4(object.matrixWorld);
|
|
geometries.push(geometry);
|
|
}
|
|
});
|
|
|
|
var result = mergeGeometries(geometries);
|
|
result.computeBoundingBox();
|
|
result.computeBoundingSphere();
|
|
result.materials = materials;
|
|
result.thickness = result.boundingBox.getSize(new THREE.Vector3()).length() * 0.01;
|
|
return result;
|
|
}
|
|
|
|
function unwrapMesh(geometry, resolution, gutter, preserveNormalSeams, maxIterations, maxChartSize) {
|
|
var positions = geometry.attributes.position.array;
|
|
var indices = new Uint32Array(geometry.index.array);
|
|
|
|
var triangle = new THREE.Triangle();
|
|
var avgTriangleArea = 0;
|
|
for (var i = 0; i < indices.length; i += 3) {
|
|
triangle.a.fromBufferAttribute(geometry.attributes.position, indices[i + 0]);
|
|
triangle.b.fromBufferAttribute(geometry.attributes.position, indices[i + 1]);
|
|
triangle.c.fromBufferAttribute(geometry.attributes.position, indices[i + 2]);
|
|
avgTriangleArea += triangle.getArea() / (indices.length / 3);
|
|
}
|
|
|
|
var atlas = new Atlas();
|
|
atlas.addMesh({
|
|
vertexPositionData: positions,
|
|
vertexCount: positions.length / 3,
|
|
vertexPositionStride: 12,
|
|
vertexNormalData: geometry.attributes.normal.array,
|
|
vertexNormalStride: 12,
|
|
indexData: indices,
|
|
indexCount: indices.length,
|
|
});
|
|
atlas.generate(
|
|
{ normalSeamWeight: preserveNormalSeams ? 1000 : 0, maxIterations: maxIterations, maxChartArea: maxChartSize * avgTriangleArea },
|
|
{ resolution: resolution, padding: gutter }
|
|
);
|
|
|
|
var mesh = atlas.getMesh(0);
|
|
var atlasIndices = new Uint32Array(mesh.indexCount);
|
|
var uv = new Float32Array(mesh.indexCount * 2);
|
|
mesh.getIndexArray(atlasIndices);
|
|
for (var i = 0; i < mesh.indexCount; ++i) {
|
|
var v = mesh.getVertex(atlasIndices[i]);
|
|
uv[i * 2 + 0] = v.uv[0] / atlas.width;
|
|
uv[i * 2 + 1] = v.uv[1] / atlas.height;
|
|
}
|
|
atlas.delete();
|
|
|
|
// uvs are computed per corner, for simplicity we deindex the geometry and expect caller to reindex
|
|
geometry = geometry.toNonIndexed();
|
|
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
return geometry;
|
|
}
|
|
|
|
function generateTangents(geometry, sign = 1) {
|
|
var attr = geometry.attributes;
|
|
var indices = geometry.index ? new Uint32Array(geometry.index.array) : null;
|
|
var tangents = MeshoptTangents.generateTangents(indices, attr.position.array, 3, attr.normal.array, 3, attr.uv.array, 2);
|
|
for (var i = 3; i < tangents.length; i += 4) tangents[i] *= sign;
|
|
|
|
// tangents are computed per corner, for simplicity we deindex the geometry and expect caller to reindex
|
|
if (indices) geometry = geometry.toNonIndexed();
|
|
geometry.setAttribute('tangent', new THREE.BufferAttribute(tangents, 4));
|
|
return geometry;
|
|
}
|
|
|
|
function generateNormals(geometry, creaseAngle, smoothing) {
|
|
var attr = geometry.attributes;
|
|
var indices = geometry.index ? new Uint32Array(geometry.index.array) : null;
|
|
var normals = MeshoptTangents.generateNormals(indices, attr.position.array, 3, creaseAngle, smoothing);
|
|
|
|
// normals are computed per corner, for simplicity we deindex the geometry and expect caller to reindex
|
|
geometry = indices ? geometry.toNonIndexed() : geometry.clone();
|
|
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
return geometry;
|
|
}
|
|
|
|
function showTexture(id, texture) {
|
|
var canvas = document.getElementById(id);
|
|
canvas.style.display = texture ? 'block' : 'none';
|
|
if (!texture) return;
|
|
|
|
canvas.width = texture.image.width;
|
|
canvas.height = texture.image.height;
|
|
canvas.getContext('2d').putImageData(new ImageData(new Uint8ClampedArray(texture.image.data), canvas.width, canvas.height), 0, 0);
|
|
}
|
|
|
|
function clearRemeshed() {
|
|
if (remeshed) {
|
|
scene.remove(remeshed);
|
|
remeshed.geometry.dispose();
|
|
}
|
|
remeshed = undefined;
|
|
|
|
if (remeshMaterial.map) remeshMaterial.map.dispose();
|
|
remeshMaterial.map = null;
|
|
|
|
if (remeshMaterial.normalMap) remeshMaterial.normalMap.dispose();
|
|
remeshMaterial.normalMap = null;
|
|
|
|
// roughness and metalness share one texture, so we only dispose once
|
|
if (remeshMaterial.metalnessMap) remeshMaterial.metalnessMap.dispose();
|
|
remeshMaterial.metalnessMap = null;
|
|
remeshMaterial.roughnessMap = null;
|
|
|
|
showTexture('texture-view', null);
|
|
showTexture('normal-view', null);
|
|
showTexture('material-view', null);
|
|
}
|
|
|
|
function simplifyMesh(geo, target, threshold, flags, solve) {
|
|
var positions = solve ? new Float32Array(geo.attributes.position.array) : geo.attributes.position.array;
|
|
var indices = solve ? new Uint32Array(geo.index.array) : geo.index.array;
|
|
|
|
var res;
|
|
if (solve) {
|
|
res = MeshoptSimplifier.simplifyWithUpdate(indices, positions, 3, new Float32Array(), 0, [], null, target * 3, threshold, flags);
|
|
res[0] = indices.slice(0, res[0]);
|
|
} else {
|
|
res = MeshoptSimplifier.simplify(indices, positions, 3, target * 3, threshold, flags);
|
|
}
|
|
|
|
var [remap, unique] = MeshoptSimplifier.compactMesh(res[0]);
|
|
var compact = new Float32Array(unique * 3);
|
|
for (var i = 0; i < remap.length; ++i) {
|
|
if (remap[i] < unique) compact.set(positions.subarray(i * 3, i * 3 + 3), remap[i] * 3);
|
|
}
|
|
|
|
var rgeo = geo.clone();
|
|
rgeo.setAttribute('position', new THREE.BufferAttribute(compact, 3));
|
|
rgeo.setIndex(new THREE.BufferAttribute(res[0], 1));
|
|
|
|
return rgeo;
|
|
}
|
|
|
|
function showProgress(stage) {
|
|
progressLabel.textContent = stage;
|
|
progressLabel.hidden = false;
|
|
return new Promise(requestAnimationFrame);
|
|
}
|
|
|
|
async function remesh() {
|
|
if (!merged) return;
|
|
|
|
await Promise.all([MeshoptSimplifier.ready, MeshoptTangents.ready, initializeWatlas()]);
|
|
|
|
await showProgress('remesh');
|
|
var flags = [];
|
|
if (settings.thicken) flags.push('Thicken');
|
|
if (settings.shell) flags.push('Shell');
|
|
if (settings.solve) flags.push('Solve');
|
|
if (settings.debug) flags.push('_InternalDebug');
|
|
|
|
var result = MeshoptSimplifier.remesh(merged.index.array, merged.attributes.position.array, 3, settings.resolution, flags);
|
|
var geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(result, 3));
|
|
geometry.setIndex(new THREE.BufferAttribute(MeshoptSimplifier.generatePositionRemap(result, 3), 1));
|
|
|
|
if (settings.simplify.enabled) {
|
|
await showProgress('simplify');
|
|
var sflags = ['PreserveFolds', 'Sparse']; // Sparse is needed because we skip position compaction above to be able to use quick remap to generate indices
|
|
if (settings.simplify.regularize) sflags.push('RegularizeLight');
|
|
var error = settings.simplify.forceTriangleCount ? 1 : Math.pow(10, -settings.simplify.errorThresholdLog10);
|
|
if (settings.simplify.errorAuto) {
|
|
sflags.push('ErrorAbsolute'); // merged mesh is in world space, so we don't need to adjust error by mesh scale and can simply use world space units
|
|
var distance = Math.max(camera.position.distanceTo(merged.boundingSphere.center) - merged.boundingSphere.radius, 0);
|
|
error = distance * 1e-3 * (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) * 0.5)); // ~1 pixel at 1k x 1k
|
|
}
|
|
geometry = simplifyMesh(geometry, settings.simplify.forceTriangleCount, error, sflags, settings.simplify.solve);
|
|
}
|
|
|
|
await showProgress('normals');
|
|
geometry = mergeVertices(
|
|
generateNormals(geometry, THREE.MathUtils.degToRad(settings.debug ? 30 : settings.normalCrease), settings.normalSmoothing)
|
|
);
|
|
|
|
var material = remeshMaterial.clone();
|
|
material.map = material.normalMap = material.metalnessMap = material.roughnessMap = null;
|
|
material.vertexColors = false;
|
|
material.color.set(0xffffff);
|
|
material.metalness = 0;
|
|
material.roughness = 1;
|
|
|
|
if (settings.paint.mode != 'none' && !paintCache) {
|
|
await showProgress('paint cache');
|
|
paintCache = repaint.buildCache(merged, merged.materials);
|
|
}
|
|
|
|
if (settings.paint.mode == 'vertex') {
|
|
await showProgress('repaint');
|
|
repaint.transferColors(renderer, geometry, paintCache, merged.thickness);
|
|
material.vertexColors = true;
|
|
} else if (settings.paint.mode == 'facets') {
|
|
await showProgress('repaint');
|
|
geometry = geometry.toNonIndexed();
|
|
geometry.computeVertexNormals();
|
|
var indices = new Uint32Array(geometry.attributes.position.count);
|
|
for (var i = 0; i < indices.length; ++i) indices[i] = i;
|
|
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
repaint.transferColors(renderer, geometry, paintCache, merged.thickness, true);
|
|
material.vertexColors = true;
|
|
} else if (settings.paint.mode == 'texture') {
|
|
await showProgress('unwrap');
|
|
var maxIterations = settings.paint.atlasMode == 'quality' ? 1 : 0;
|
|
var maxChartSize = settings.paint.atlasMode == 'fast' ? 256 : settings.paint.atlasMode == 'balanced' ? 512 : 1024;
|
|
var preserveSeams = settings.paint.preserveNormalSeams;
|
|
geometry = unwrapMesh(geometry, settings.paint.resolution, settings.paint.gutter, preserveSeams, maxIterations, maxChartSize);
|
|
if (settings.paint.normals) {
|
|
await showProgress('tangents');
|
|
geometry = generateTangents(geometry);
|
|
}
|
|
geometry = mergeVertices(geometry);
|
|
|
|
await showProgress('repaint');
|
|
var textures = repaint.transferTexture(
|
|
renderer,
|
|
geometry,
|
|
paintCache,
|
|
merged.thickness,
|
|
settings.paint.resolution,
|
|
settings.paint.gutter,
|
|
settings.paint.infill,
|
|
settings.paint.normals,
|
|
settings.paint.materials
|
|
);
|
|
|
|
material.map = textures.map;
|
|
material.normalMap = textures.normalMap;
|
|
|
|
if (settings.paint.materials) {
|
|
material.metalnessMap = textures.materialMap;
|
|
material.roughnessMap = textures.materialMap;
|
|
material.metalness = 1;
|
|
} else if (settings.paint.normals) {
|
|
// we need this to have a specular response
|
|
material.roughness = 0.5;
|
|
}
|
|
} else {
|
|
material.color.set(0xdddddd);
|
|
}
|
|
material.needsUpdate = true;
|
|
|
|
// remesh material + wireframe overlay material
|
|
geometry.addGroup(0, geometry.index.count, 0);
|
|
geometry.addGroup(0, geometry.index.count, 1);
|
|
|
|
clearRemeshed();
|
|
remeshMaterial.dispose();
|
|
remeshMaterial = material;
|
|
remeshed = new THREE.Mesh(geometry, [remeshMaterial, wireframeMaterial]);
|
|
model.visible = false;
|
|
scene.add(remeshed);
|
|
showTexture('texture-view', remeshMaterial.map);
|
|
showTexture('normal-view', remeshMaterial.normalMap);
|
|
showTexture('material-view', remeshMaterial.metalnessMap);
|
|
update();
|
|
|
|
var triangles = geometry.index.count / 3;
|
|
stats.triangles = triangles;
|
|
stats.vertices = geometry.attributes.position.count;
|
|
stats.ratio = (triangles / (merged.index.count / 3)) * 100;
|
|
updateStatsDisplay();
|
|
progressLabel.hidden = true;
|
|
}
|
|
|
|
function exportGlb(name) {
|
|
// can't export remeshed directly as it uses debug materials for wireframe
|
|
var mesh = new THREE.Mesh(remeshed.geometry, remeshMaterial);
|
|
|
|
new GLTFExporter().parse(
|
|
mesh,
|
|
function (glb) {
|
|
var link = document.createElement('a');
|
|
link.href = URL.createObjectURL(new Blob([glb], { type: 'model/gltf-binary' }));
|
|
link.download = name;
|
|
link.click();
|
|
|
|
// may resolve rare race conditions
|
|
setTimeout(function () {
|
|
URL.revokeObjectURL(link.href);
|
|
}, 0);
|
|
},
|
|
function (e) {
|
|
console.log(e);
|
|
},
|
|
{ binary: true }
|
|
);
|
|
}
|
|
|
|
function revert() {
|
|
if (!model) return;
|
|
|
|
model.visible = true;
|
|
clearRemeshed();
|
|
|
|
stats.triangles = 0;
|
|
stats.vertices = 0;
|
|
stats.ratio = 100;
|
|
|
|
model.traverse(function (object) {
|
|
if (object.isMesh) {
|
|
stats.triangles += (object.geometry.index ? object.geometry.index.count : object.geometry.attributes.position.count) / 3;
|
|
stats.vertices += object.geometry.attributes.position.count;
|
|
}
|
|
});
|
|
|
|
updateStatsDisplay();
|
|
}
|
|
|
|
function updateStatsDisplay() {
|
|
stats.label.innerHTML =
|
|
'Triangles: ' + stats.triangles + '<br>' + 'Vertices: ' + stats.vertices + '<br>' + 'Ratio: ' + stats.ratio.toFixed(1) + '%';
|
|
}
|
|
|
|
function reload() {
|
|
var simp = import('/js/meshopt_simplifier.js?x=' + Date.now());
|
|
MeshoptSimplifier.ready = simp.then(function (s) {
|
|
return s.MeshoptSimplifier.ready.then(function () {
|
|
for (var prop in s.MeshoptSimplifier) {
|
|
MeshoptSimplifier[prop] = s.MeshoptSimplifier[prop];
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
var moduleLastModified = 0;
|
|
|
|
function autoReload() {
|
|
if (!settings.autoUpdate) return;
|
|
|
|
fetch('/js/meshopt_simplifier.js?x=' + Date.now(), { method: 'HEAD' })
|
|
.then(function (r) {
|
|
var last = r.headers.get('Last-Modified');
|
|
if (last != moduleLastModified) {
|
|
moduleLastModified = last;
|
|
reload();
|
|
remesh();
|
|
}
|
|
|
|
settings.autoUpdateStatus = new Date(last).toLocaleTimeString();
|
|
setTimeout(autoReload, 1000);
|
|
})
|
|
.catch(function (e) {
|
|
settings.autoUpdateStatus = 'error';
|
|
setTimeout(autoReload, 5000);
|
|
});
|
|
}
|
|
|
|
function update() {
|
|
remeshMaterial.polygonOffset = settings.wireframeOverlay;
|
|
wireframeMaterial.visible = settings.wireframeOverlay;
|
|
|
|
scene.traverse(function (child) {
|
|
if (child.isMesh) {
|
|
if (Array.isArray(child.material)) {
|
|
child.material[0].wireframe = settings.wireframe;
|
|
} else {
|
|
child.material.wireframe = settings.wireframe;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function load(path, ext) {
|
|
if (model) scene.remove(model);
|
|
clearRemeshed();
|
|
|
|
if (paintCache) paintCache.dispose();
|
|
paintCache = null;
|
|
|
|
model = undefined;
|
|
merged = undefined;
|
|
|
|
var onProgress = function (xhr) {};
|
|
var onError = function (e) {
|
|
console.log(e);
|
|
};
|
|
|
|
function center(model) {
|
|
var bbox = new THREE.Box3().setFromObject(model);
|
|
var scale = 2 / Math.max(bbox.max.x - bbox.min.x, bbox.max.y - bbox.min.y, bbox.max.z - bbox.min.z);
|
|
var offset = new THREE.Vector3().addVectors(bbox.max, bbox.min).multiplyScalar(scale / 2);
|
|
|
|
model.scale.set(scale, scale, scale);
|
|
model.position.set(-offset.x, -offset.y, -offset.z);
|
|
}
|
|
|
|
function finish(object) {
|
|
model = object;
|
|
center(model);
|
|
scene.add(model);
|
|
merged = mergeMesh(model);
|
|
revert();
|
|
controls.target.set(0, 0, 0);
|
|
}
|
|
|
|
if (ext == 'gltf' || ext == 'glb') {
|
|
var loader = new GLTFLoader();
|
|
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
loader.load(
|
|
path,
|
|
function (gltf) {
|
|
finish(gltf.scene);
|
|
},
|
|
onProgress,
|
|
onError
|
|
);
|
|
} else if (ext == 'obj') {
|
|
var loader = new OBJLoader();
|
|
loader.load(
|
|
path,
|
|
function (obj) {
|
|
finish(obj);
|
|
},
|
|
onProgress,
|
|
onError
|
|
);
|
|
} else {
|
|
console.error('Unsupported file format');
|
|
}
|
|
}
|
|
|
|
function loadDefault() {
|
|
createView('pirate.glb');
|
|
load('pirate.glb', 'glb');
|
|
}
|
|
|
|
function init() {
|
|
container = document.getElementById('container');
|
|
|
|
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.y = 1.0;
|
|
camera.position.z = 3.0;
|
|
|
|
window.addEventListener('resize', updateRendererSizes, false);
|
|
}
|
|
|
|
function updateRendererSizes() {
|
|
var rendererContainer = renderer.domElement.parentElement;
|
|
var aspectRatio = rendererContainer.clientWidth / rendererContainer.clientHeight;
|
|
|
|
camera.aspect = aspectRatio;
|
|
camera.updateProjectionMatrix();
|
|
|
|
renderer.setSize(rendererContainer.clientWidth, rendererContainer.clientHeight);
|
|
}
|
|
|
|
function render() {
|
|
requestAnimationFrame(render);
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|