Introduction
하는 일이 3D Domain 에 편중되어 있다보니, 사내에서 보고를 위한 간단한 데모를 제작할 때나, 혹은 논문 project page 를 만드는 등 web page 에서 3D Model 을 rendering 해서 보여줄 일이 가끔 있다.
웹에 대해 잘 모르고 간단히 쓸만한 방법으론
- Google Model Viewer 사용하거나
- Threejs 기반으로 간단한 viewer 를 직접 제작하거나
두 가지 정도가 있는데, 이 글에서는 개인적으로 느낀 각 방법의 사용법과 장단점에 대해 탐구해보고, custom 으로 제작한 Threejs 기반의 simple model viewer 에 대해서도 공유하는 시간을 갖도록 해보자. 구현한 custom threejs model viewer 만 궁금한 경우에는 여기 를 참조하면 된다!
Google Model Viewer
Model Viewer 는 구글에서 배포하는 간단한 3D Model viewing 용 패키지이다. 간단한 3D model viewer 를 필요할 때는 손쉽게 사용이 가능하며, CaPa project page 의 3D model viewer 도 google model viewer 를 이용해 만들었다.
Basic Usage
cdn 으로 다음과 같이 html file 에서 import 한 후,
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
model-viewer 를 선언하면서 src 에 3D model path 만 입력하면 interactive 한 3D Model 을 웹에 바로 삽입할 수 있다.
<model-viewer
src="your_3d_model.glb" >
</model-viewer>
이 밖에도
-
auto-rotate -
rotation-per-second -
camera-orbit: $ (\theta, \phi, r) $ -
exposure -
skybox-image
등의 옵션을 제공하고 있어, model load 시 camera angle 이나 environment 등을 적용한 rendering 을 손쉽게 구현할 수 있다.
Example:
<model-viewer
src="omni.glb"
auto-rotate
rotation-per-second="60deg"
camera-orbit="0deg 90deg 5m"
exposure="3"
skybox-image="sunrise_1K_hdr.hdr" >
</model-viewer>
Geometry Rendering
이처럼 간단한 3D model viewer 로는 더할 나위가 없지만, model viewer 는 기본 shader 를 수정하기가 힘들고, 기본 texture rendering 외에 다른 타입 (Normal, Geometry, Wireframe) 등을 지원하지 않아서, texture 외의 detail 한 mesh 요소를 눈으로 확인하기가 힘들다.
대신에 제한적으로 texture 를 지우고 enviroment 에 gradient equirectangular image 를 사용하여, PBR rendering 으로 gradient environment 를 반사하는 rendering 을 만들어서 mesh geometry 를 rendering 하는 듯한 효과를 낼 수 있다.
위 이미지를 mesh environment 으로 설정하고 original texture 를 없애주는 코드를 javascript 로 다음과 같이 작성할 수 있다.
var window_state = {};
function show_geometry(){
let modelViewer = document.getElementById('model');
if (modelViewer.model.materials[0].pbrMetallicRoughness.baseColorTexture.texture === null) return;
window_state.textures = [];
for (let i = 0; i < modelViewer.model.materials.length; i++) {
window_state.textures.push(modelViewer.model.materials[i].pbrMetallicRoughness.baseColorTexture.texture);
}
window_state.exposure = modelViewer.exposure;
modelViewer.environmentImage = '<img src="gradient.jpg" width="100%" />';
for (let i = 0; i < modelViewer.model.materials.length; i++) {
modelViewer.model.materials[i].pbrMetallicRoughness.baseColorTexture.setTexture(null);
}
modelViewer.exposure = 3;
}
original texture 를 저장해놨다가 되돌리는 function 을 작성해 button ui 에 mapping 하면 다음과 같이 texture / geometry 를 번갈아가면서 보여주는 model viewer 를 제공할 수 있다.
Threejs-Based Custom Viewer
위에서 살펴본 model viewer 만으로 rendering 할 수 없는 normal, wireframe rendering 등을 구현하기 위해서는 threejs 를 사용해서 직접 model viewer class 를 구현해야 한다.
Basic Usage
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.150.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.157.0/examples/jsm/"
}
}
</script>
Model Viewer 와 마찬가지로 cdn 으로 import 해서 쓸 수 있지만, threejs 자체는 기본적인 설정이 model viewer 보다 훨씬 복잡하다.
scene, camera, renderer 등을 모두 직접 설정해줘야 하는데, jacascript 에서 다음과 같이 필요한 최소 패키지들을 import 한 후,
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
scene 과 camera 를 새로 정의하고
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
renderer = new THREE.WebGLRenderer({ antialias: true });
controls = new OrbitControls(this.camera, this.renderer.domElement);
다음과 같이 gltf 3D model 을 load 하여 scene 에 불러올 수 있다.
loader = new GLTFLoader();
loader.load('your_3d_model.glb', (gltf) => { scene.add(gltf.scene); }, undefined, (error) => { console.error('Loading Error:', error); });
Custom Viewer Implementation
Three.js를 사용한 커스텀 뷰어는 Google Model Viewer에 비해 훨씬 더 많은 유연성을 제공한다. 이를 통해 Diffuse, Mesh, Wireframe, Normal 등 다양한 렌더링 모드를 구현할 수 있다.
Threejs 의 Mesh Texture 는 MeshStandardMaterial 에 PBR material 형태로 정의되어 있는데, Normal, Geometry, Wireframe 등의 rendering 은 material mapping 만 적절히 해주는 것으로 구현 가능하다.
Normal Map
예를 들어 Normal map 의 경우에는 threejs 에서 제공하는 THREE.MeshNormalMaterial() 을 mesh material 로 설정하는 것으로 rendering 할 수 있다.
if (model) {
model.traverse((child) => {
if (child.isMesh) {
child.material = new THREE.MeshNormalMaterial();
}
});
}
Wireframe
Wireframe 의 경우에는 THREE.MeshNormalMaterial() 에서 wireframe: true 로 설정하면 wireframe mesh 가 나오긴 하지만, 이 모드는 원본 texture 랑 geometry 가 사라져서 가시성이 떨어진다.
대신에 wireframemesh 를 복사본으로 선언한 후, original mesh 의 child 로 추가하여 mesh 의 original texture, geometry 와 함께 wireframe 을 렌더링하여 볼 수 있다.
model.traverse((child) => {
if (child.isMesh) {
const wireframeMesh = new THREE.Mesh(child.geometry, new THREE.MeshBasicMaterial({
wireframe: true,
color: 0xaaaaaa, // light gray
depthTest: true,
transparent: true,
opacity: 0.8 // transparency
}));
model.add(wireframeMesh); // add as child
}
});
Custom Model Viewer
3D Model 에 따라 이 기능들을 항상 재구현 하기엔 좀 귀찮으므로, 앞서 설명한 기능들과 더불어 성격이 다른 pre-defined skybox env map 설정 및 제거,모델의 위치와 회전을 조정할 수 있는 UI 패널 등을 추가한 SimpleModelViewer class 를 만들어보았다.
model viewer 의 소스코드는 아래를 참조:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
class SimpleModelViewer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = <style> :host { display: block; border: 2px solid #ccc; border-radius: 8px;} #loadingProgressBar { position: absolute; top: 0; left: 0; width: 0%; height: 5px; background-color: #4CAF50; z-index: 1; display: none; } #canvas-container { width: 100%; height: 100%; position: relative; } canvas { width: 100%; height: 100%; } .controls { margin: 5px; } button { background-color: #04AA6D; border: none; color: white; padding: 5px 10px; border-radius: 4px; cursor: pointer; text-align: center; text-decoration: none; display: inline-block; font-size: 0.8rem; } button:hover { background-color: #3e8e41 } .transform-panel { position: absolute; top: 0.5rem; right: 0.5rem; font-size: 0.8rem; background-color: rgba(200, 200, 200, 0.8); padding: 0.5rem; border-radius: 5px; display: flex; flex-direction: column; gap: 3px; z-index: 1000; } .transform-panel label { display: flex; justify-content: space-between; align-items: center; } .transform-panel input { width: 3rem; } </style> <div class="controls"> <div id='meta'> <button id="textureBtn">Diffuse</button> <button id="meshBtn">Geometry</button> <button id="normalBtn">Normal</button> <button id="wireframeBtn">Wireframe</button> <button id="autoRotateBtn">Auto-Rotate</button> <button id="toonShadingBtn">Toon Shading</button> <button id="setBgBtn1">Env1</button> <button id="setBgBtn2">Env2</button> <button id="setBgBtn3">Env3</button> <button id="removeBgBtn">Remove Env</button> <div id="modelInfo" style='padding-left: 0.1rem; font-size:0.8rem'><strong>[Model Info]</strong> loading...</div> </div> <div id="transform-container" style="position: relative;"> <div class="transform-panel"> <button id="togglePanelBtn"><i class="bi bi-caret-left"></i></button> <div id="transformControls" style="display: block;"> <label>Position X: <input type="number" id="posX" step="0.1" value="0"></label> <label>Position Y: <input type="number" id="posY" step="0.1" value="0"></label> <label>Position Z: <input type="number" id="posZ" step="0.1" value="0"></label> <label>Rotation X (deg): <input type="number" id="rotX" step="1" value="0"></label> <label>Rotation Y (deg): <input type="number" id="rotY" step="1" value="0"></label> <label>Rotation Z (deg): <input type="number" id="rotZ" step="1" value="0"></label> <div>Scale: <input type="range" id="scale" style="width: 7rem;" min="1" max="20" step="0.1" value="8"></div> <div>Roughness: <input type="range" id="roughness" style="width: 7rem;" min="0" max="1" step="0.01" value="0.5"></div> <div>Metalness: <input type="range" id="metalness" style="width: 7rem;" min="0" max="1" step="0.01" value="0.5"></div> </div> </div> </div> </div> <div id="canvas-container" style='text-align: center'> <div id="loadingProgressBar"></div> </div> ;
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true
});
this.renderer.setPixelRatio(window.devicePixelRatio);
// this.renderer.outputEncoding = THREE.LinearEncoding;
this.renderer.outputEncoding = THREE.sRGBEncoding;
this.renderer.setClearColor(0xeeeeee, 1);
this.renderer.shadowMap.enabled = true;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.0;
this.shadowRoot.querySelector('#canvas-container').appendChild(this.renderer.domElement);
// const ambient = new THREE.AmbientLight(0x404040, 0.5);
// const directional = new THREE.DirectionalLight(0xffffff, 1);
// directional.position.set(5, 10, 7.5);
// directional.castShadow = true;
// this.scene.add(ambient, directional);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.addEventListener('change', () => this.updateControlPanel());
this.textureLoader = new THREE.TextureLoader();
this.whiteTexture = this.textureLoader.load('https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/white.jpg');
this.whiteTexture.mapping = THREE.EquirectangularReflectionMapping;
this.gradTexture = this.textureLoader.load('https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/gradient.jpg');
this.gradTexture.mapping = THREE.EquirectangularReflectionMapping;
this.loader = new GLTFLoader();
this.model = null;
this.originalMaterials = {};
this.wireframeMeshes = [];
this.modelSize = 8;
this.autoRotate = false;
this.anglePerSecond = 30;
this.lastTime = 0;
this.toonEnabled = false;
this.toonMaterial = null;
this.standardMaterials = [];
this.initEventListeners();
}
connectedCallback() {
this.resizeRenderer();
this.animate(0);
}
static get observedAttributes() {
return ['src', 'auto-rotate', 'angle-per-second', 'camera-orbit', 'hide-control-ui'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'src' && newValue) {
this.loadModel(newValue);
} else if (name === 'auto-rotate') {
this.autoRotate = newValue !== null;
} else if (name === 'angle-per-second') {
this.anglePerSecond = parseFloat(newValue) || 30;
} else if (name === 'camera-orbit') {
this.setCameraOrbit(newValue);
} else if (name === 'hide-control-ui') {
const controlsDiv = this.shadowRoot.querySelector('.controls');
if (newValue !== null) {
controlsDiv.style.display = 'none';
} else {
controlsDiv.style.display = 'block';
}
}
}
initEventListeners() {
this.shadowRoot.querySelector('#textureBtn').addEventListener('click', () => this.showTexture());
this.shadowRoot.querySelector('#meshBtn').addEventListener('click', () => this.showMesh());
this.shadowRoot.querySelector('#wireframeBtn').addEventListener('click', () => this.showWireframe());
this.shadowRoot.querySelector('#normalBtn').addEventListener('click', () => this.showNormal());
this.shadowRoot.querySelector('#setBgBtn1').addEventListener('click', () => this.setBackground1());
this.shadowRoot.querySelector('#setBgBtn2').addEventListener('click', () => this.setBackground2());
this.shadowRoot.querySelector('#setBgBtn3').addEventListener('click', () => this.setBackground3());
this.shadowRoot.querySelector('#removeBgBtn').addEventListener('click', () => this.removeBG());
this.shadowRoot.querySelector('#posX').addEventListener('input', () => this.updateModelTransform());
this.shadowRoot.querySelector('#posY').addEventListener('input', () => this.updateModelTransform());
this.shadowRoot.querySelector('#posZ').addEventListener('input', () => this.updateModelTransform());
this.shadowRoot.querySelector('#rotX').addEventListener('input', () => this.updateModelTransform());
this.shadowRoot.querySelector('#rotY').addEventListener('input', () => this.updateModelTransform());
this.shadowRoot.querySelector('#rotZ').addEventListener('input', () => this.updateModelTransform());
this.shadowRoot.querySelector('#roughness').addEventListener('input', () => this.updateMaterialProperties());
this.shadowRoot.querySelector('#metalness').addEventListener('input', () => this.updateMaterialProperties());
this.shadowRoot.querySelector('#scale').addEventListener('input', (e) => {
this.modelSize = parseFloat(e.target.value);
if (this.model) this.model.scale.set(this.modelSize, this.modelSize, this.modelSize);
});
this.shadowRoot.querySelector('#autoRotateBtn').addEventListener('click', () => {
this.autoRotate = !this.autoRotate;
this.shadowRoot.querySelector('#autoRotateBtn').textContent = this.autoRotate ? 'Auto-Rotate On' : 'Auto-Rotate Off';
});
this.shadowRoot.querySelector('#togglePanelBtn').addEventListener('click', () => {
const controls = this.shadowRoot.querySelector('#transformControls');
const button = this.shadowRoot.querySelector('#togglePanelBtn');
if (controls.style.display === 'none') {
controls.style.display = 'block';
button.innerHTML = '<i class="bi bi-caret-left-fill"></i>';
} else {
controls.style.display = 'none';
button.innerHTML = '<i class="bi bi-caret-left"></i>';
}
});
this.shadowRoot.querySelector('#toonShadingBtn').addEventListener('click', () => {
this.toonEnabled = !this.toonEnabled;
if (this.toonEnabled) {
this.enableToonShading();
this.shadowRoot.querySelector('#toonShadingBtn').textContent = 'Toon Shading Off';
} else {
this.disableToonShading();
this.shadowRoot.querySelector('#toonShadingBtn').textContent = 'Toon Shading On';
}
});
}
updateControlPanel() {
if (this.model) {
this.shadowRoot.querySelector('#posX').value = this.model.position.x.toFixed(1);
this.shadowRoot.querySelector('#posY').value = this.model.position.y.toFixed(1);
this.shadowRoot.querySelector('#posZ').value = this.model.position.z.toFixed(1);
this.shadowRoot.querySelector('#rotX').value = THREE.MathUtils.radToDeg(this.model.rotation.x).toFixed(0);
this.shadowRoot.querySelector('#rotY').value = THREE.MathUtils.radToDeg(this.model.rotation.y).toFixed(0);
this.shadowRoot.querySelector('#rotZ').value = THREE.MathUtils.radToDeg(this.model.rotation.z).toFixed(0);
}
}
updateModelTransform() {
if (this.model) {
const posX = parseFloat(this.shadowRoot.querySelector('#posX').value);
const posY = parseFloat(this.shadowRoot.querySelector('#posY').value);
const posZ = parseFloat(this.shadowRoot.querySelector('#posZ').value);
this.model.position.set(posX, posY, posZ);
const rotX = THREE.MathUtils.degToRad(parseFloat(this.shadowRoot.querySelector('#rotX').value));
const rotY = THREE.MathUtils.degToRad(parseFloat(this.shadowRoot.querySelector('#rotY').value));
const rotZ = THREE.MathUtils.degToRad(parseFloat(this.shadowRoot.querySelector('#rotZ').value));
this.model.rotation.set(rotX, rotY, rotZ);
}
}
showTexture() {
if (this.model) {
this.model.traverse((child) => {
if (child.isMesh) {
child.material = new THREE.MeshBasicMaterial({ map: this.originalMaterials[child.uuid].map });
}
});
}
}
showMesh() {
if (this.model) {
this.model.traverse((child) => {
if (child.isMesh) {
child.material = new THREE.MeshStandardMaterial({
color: 0xffffff,
map: null,
envMap: this.gradTexture,
envMapIntensity: 1.0,
roughness: 1,
metalness: 1
});
}
});
}
}
showWireframe() {
if (this.model) {
if (this.wireframeMeshes.length > 0) {
this.wireframeMeshes.forEach((mesh) => this.model.remove(mesh));
this.wireframeMeshes = [];
} else {
this.model.traverse((child) => {
if (child.isMesh) {
const wireframeMesh = new THREE.Mesh(child.geometry, new THREE.MeshBasicMaterial({
wireframe: true,
color: 0xaaaaaa,
depthTest: true,
transparent: true,
opacity: 0.8
}));
this.model.add(wireframeMesh);
this.wireframeMeshes.push(wireframeMesh);
}
});
}
}
}
showNormal() {
if (this.model) {
this.model.traverse((child) => {
if (child.isMesh) {
child.material = new THREE.MeshNormalMaterial();
}
});
}
}
set_bg(url, rgbeLoader) {
rgbeLoader.load(url, (texture) => {
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.mapping = THREE.EquirectangularReflectionMapping;
this.scene.background = texture;
this.scene.environment = texture;
if (this.model) {
let initialRoughness = 0.5, initialMetalness = 0.5;
this.model.traverse((child) => {
if (child.isMesh) {
const originalMaterial = this.originalMaterials[child.uuid];
const isStandardMaterial = originalMaterial instanceof THREE.MeshStandardMaterial;
child.material = new THREE.MeshStandardMaterial({
map: originalMaterial.map ? originalMaterial.map.clone() : null,
envMap: texture,
envMapIntensity: 1.0,
roughness: isStandardMaterial && originalMaterial.roughness !== undefined ? originalMaterial.roughness : 0.5,
metalness: isStandardMaterial && originalMaterial.metalness !== undefined ? originalMaterial.metalness : 0.5,
normalMap: originalMaterial.normalMap ? originalMaterial.normalMap : null,
emissiveMap: originalMaterial.emissiveMap ? originalMaterial.emissiveMap : null,
});
if (child.material.map) {
child.material.map.encoding = THREE.sRGBEncoding;
}
if (child.material.emissiveMap) {
child.material.emissiveMap.encoding = THREE.sRGBEncoding;
}
child.material.needsUpdate = true;
// ui init
if (!initialRoughness && !initialMetalness && isStandardMaterial) {
initialRoughness = child.material.roughness;
initialMetalness = child.material.metalness;
}
}
});
const roughnessInput = this.shadowRoot.querySelector('#roughness');
const metalnessInput = this.shadowRoot.querySelector('#metalness');
if (roughnessInput) roughnessInput.value = initialRoughness;
if (metalnessInput) metalnessInput.value = initialMetalness;
}
this.renderer.render(this.scene, this.camera);
}, undefined, (err) => {
console.error('Skybox err:', err);
alert('Cannot load Skybox Image');
});
}
setBackground1() {
const url = 'https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/spruit_sunrise_1k_HDR.hdr';
const rgbeLoader = new RGBELoader();
this.set_bg(url, rgbeLoader);
}
setBackground2() {
const url = 'https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/aircraft_workshop_01_1k.hdr';
const rgbeLoader = new RGBELoader();
this.set_bg(url, rgbeLoader);
}
setBackground3() {
const url = 'https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/lebombo_1k.hdr';
const rgbeLoader = new RGBELoader();
this.set_bg(url, rgbeLoader);
}
removeBG() {
this.scene.background = null;
this.scene.environment = null;
if (this.model) {
this.model.traverse((child) => {
if (child.isMesh) {
child.material = new THREE.MeshBasicMaterial({ map: this.originalMaterials[child.uuid].map, envMap: null });
child.material.needsUpdate = true;
}
});
}
}
setCameraOrbit(value) {
const [x, y, z] = value.split(' ').map(parseFloat);
if (!isNaN(x) && !isNaN(y) && !isNaN(z)) {
this.camera.position.set(x, y, z);
this.camera.lookAt(0, 0, 0);
}
}
loadModel(url) {
const progressBar = this.shadowRoot.querySelector('#loadingProgressBar');
progressBar.style.display = 'block';
progressBar.style.width = '0%'
this.loader.load(url, (gltf) => {
if (this.model) {
this.scene.remove(this.model);
}
this.model = gltf.scene;
this.model.scale.set(this.modelSize, this.modelSize, this.modelSize);
const box = new THREE.Box3().setFromObject(this.model);
const center = box.getCenter(new THREE.Vector3());
this.model.position.sub(center);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const fov = this.camera.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
const cameraOrbit = this.getAttribute('camera-orbit');
if (cameraOrbit) {
this.setCameraOrbit(cameraOrbit);
} else {
this.camera.position.set(0, 0, cameraZ * 1.5);
this.camera.lookAt(0, 0, 0);
}
let vertexCount = 0, faceCount = 0;
this.standardMaterials = [];
let initialRoughness = 0.5;
let initialMetalness = 0.5;
let standardMaterialFound = false;
this.model.traverse((child) => {
if (child.isMesh && child.geometry) {
vertexCount += child.geometry.attributes.position.count;
faceCount += child.geometry.index ? child.geometry.index.count / 3 : child.geometry.attributes.position.count / 3;
this.originalMaterials[child.uuid] = child.material;
if (child.material instanceof THREE.MeshStandardMaterial) {
this.standardMaterials.push(child.material);
if (!standardMaterialFound) {
initialRoughness = child.material.roughness || 0.5;
initialMetalness = child.material.metalness || 0.5;
standardMaterialFound = true;
const roughnessInput = this.shadowRoot.querySelector('#roughness');
const metalnessInput = this.shadowRoot.querySelector('#metalness');
if (roughnessInput) roughnessInput.value = initialRoughness;
if (metalnessInput) metalnessInput.value = initialMetalness;
}
const material = child.material;
if (material.map) {
material.map.encoding = THREE.sRGBEncoding;
}
if (material.emissiveMap) {
material.emissiveMap.encoding = THREE.sRGBEncoding;
}
material.needsUpdate = true;
} else {
// not Standard
child.material = new THREE.MeshStandardMaterial({
map: child.material.map,
roughness: 0.5,
metalness: 0.5
});
if (child.material.map) {
child.material.map.encoding = THREE.sRGBEncoding;
}
child.material.needsUpdate = true;
this.standardMaterials.push(child.material);
}
}
});
this.shadowRoot.querySelector('#roughness').value = initialRoughness;
this.shadowRoot.querySelector('#metalness').value = initialMetalness;
this.shadowRoot.querySelector('#modelInfo').innerHTML = `<strong>[Model Info]</strong> Vertices: ${vertexCount}, Faces: ${faceCount}`;
this.scene.add(this.model);
this.showTexture();
this.updateControlPanel();
progressBar.style.display = 'none'; // hide progress bar
}, (xhr) => { // onProgress call back
if (xhr.lengthComputable) {
const percentComplete = xhr.loaded / xhr.total * 100;
progressBar.style.width = `${percentComplete}%`; // bar update
}
}, undefined, (error) => {
console.error('Loading Error:', error);
});
}
updateMaterialProperties() {
const roughnessValue = parseFloat(this.shadowRoot.querySelector('#roughness').value);
const metalnessValue = parseFloat(this.shadowRoot.querySelector('#metalness').value);
this.standardMaterials.forEach(material => {
material.roughness = roughnessValue;
material.metalness = metalnessValue;
material.needsUpdate = true;
});
if (this.model) {
this.model.traverse((child) => {
if (child.isMesh) {
const originalMaterial = this.originalMaterials[child.uuid];
const isStandardMaterial = originalMaterial instanceof THREE.MeshStandardMaterial;
child.material = new THREE.MeshStandardMaterial({
map: originalMaterial.map ? originalMaterial.map.clone() : null,
envMap: this.scene.environment || this.scene.background,
envMapIntensity: 1.0,
roughness: roughnessValue,
metalness: metalnessValue,
normalMap: originalMaterial.normalMap ? originalMaterial.normalMap : null,
emissiveMap: originalMaterial.emissiveMap ? originalMaterial.emissiveMap : null,
});
if (child.material.map) {
child.material.map.encoding = THREE.sRGBEncoding;
}
if (child.material.emissiveMap) {
child.material.emissiveMap.encoding = THREE.sRGBEncoding;
}
child.material.needsUpdate = true;
}
});
}
this.renderer.render(this.scene, this.camera);
}
animate(time) {
requestAnimationFrame((t) => this.animate(t));
const deltaTime = (time - this.lastTime) / 1000;
this.lastTime = time;
if (this.autoRotate && this.model) {
const rotationSpeed = THREE.MathUtils.degToRad(this.anglePerSecond);
this.model.rotation.y += rotationSpeed * deltaTime;
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
resizeRenderer() {
const host = this.shadowRoot.host;
const metaDiv = this.shadowRoot.querySelector('#meta');
const metaHeight = metaDiv ? metaDiv.offsetHeight : 0;
const width = host.clientWidth * 0.99;
const height = host.clientHeight - metaHeight;
this.renderer.setSize(width, height * 0.96);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
}
createToonMaterial(originalTexture = null) {
const toonMaterial = new THREE.ShaderMaterial({
uniforms: {
lightDirection: { value: new THREE.Vector3(0.5, 0.5, 1).normalize() },
outlineColor: { value: new THREE.Color(0x000000) },
toonColors: { value: [new THREE.Color(0xffffff), new THREE.Color(0xc0c0c0), new THREE.Color(0x808080)] },
toonSteps: { value: [0.8, 0.5] },
originalTexture: { value: originalTexture },
textureBlendFactor: { value: 0.8 },
outlineThickness: { value: 0.05 },
rimColor: { value: new THREE.Color(0xaaaaaa) },
rimPower: { value: 2.0 }
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vWorldPosition;
varying vec2 vUv;
void main() {
vNormal = normalize(normalMatrix * normal);
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 lightDirection;
uniform vec3 outlineColor;
uniform vec3 toonColors[3];
uniform float toonSteps[2];
uniform sampler2D originalTexture;
uniform float textureBlendFactor;
uniform float outlineThickness;
varying vec3 vNormal;
varying vec3 vWorldPosition;
varying vec2 vUv;
uniform vec3 rimColor;
uniform float rimPower;
void main() {
float diffuseIntensity = max(0.0, dot(vNormal, lightDirection));
vec3 toonColor = toonColors[0];
if (diffuseIntensity < toonSteps[0]) toonColor = toonColors[1];
if (diffuseIntensity < toonSteps[1]) toonColor = toonColors[2];
vec3 viewDir = normalize(cameraPosition - vWorldPosition);
float outlineFactor = 1.0 - max(0.0, dot(vNormal, viewDir));
float outlineThreshold = 0.7;
float outlineMix = smoothstep(outlineThreshold - outlineThickness, outlineThreshold + outlineThickness, outlineFactor);
vec3 finalToonColor = mix(toonColor, outlineColor, outlineMix);
vec4 originalTexColor = texture2D(originalTexture, vUv);
float rimFactor = 1.0 - max(0.0, dot(vNormal, viewDir));
rimFactor = pow(rimFactor, rimPower); // curvature effect
vec3 rimLighting = rimColor * rimFactor;
vec3 finalColor = mix(finalToonColor, originalTexColor.rgb, textureBlendFactor) + rimLighting;
gl_FragColor = vec4(finalColor, 1.0);
}
`
});
if (originalTexture) {
originalTexture.encoding = THREE.sRGBEncoding;
}
return toonMaterial;
}
enableToonShading() {
if (!this.model) return;
this.toonMaterial = this.toonMaterial || this.createToonMaterial();
this.model.traverse((child) => {
if (child.isMesh) {
this.originalMaterials[child.uuid] = child.material;
child.material = this.toonMaterial;
if (this.originalMaterials[child.uuid].map) {
this.toonMaterial.uniforms.originalTexture.value = this.originalMaterials[child.uuid].map;
this.toonMaterial.uniforms.originalTexture.needsUpdate = true; // Texture uniform update
} else {
this.toonMaterial.uniforms.originalTexture.value = this.whiteTexture; // White texture as default
this.toonMaterial.uniforms.originalTexture.needsUpdate = true;
}
}
});
}
disableToonShading() {
if (!this.model) return;
this.model.traverse((child) => {
if (child.isMesh && this.originalMaterials[child.uuid]) {
child.material = this.originalMaterials[child.uuid];
}
});
}
}
customElements.define('simple-model-viewer', SimpleModelViewer);
export { SimpleModelViewer };
위의 js 파일을 load 해서 threejs import 한 html 파일에 아래와 같이 simple-model-viewer 에 대한 설정만 해준다면, google model viewer class 처럼 독립적으로 여러 3D model 에 대해 web 에서 불러올 수 있다.
<simple-model-viewer
src="model.glb"
camera-orbit="0 0 20"
angle-per-second="45"
style="width: 100%; height: 800px;">
</simple-model-viewer>
<script type="module">
import { SimpleModelViewer } from 'simple-model-viewer.js';
// Resizing
window.addEventListener('resize', () => {
const viewers = document.querySelectorAll('simple-model-viewer');
viewers.forEach(viewer => viewer.resizeRenderer());
});
</script>
다른 mesh file 불러온 예시로, 이전 Deep Dive into 3D Latent Diffusion 에서 공유한 CaPa 로 만든 monster asset Result 를 첨부한다.
auto-animate 로 mesh 의 rotation y angle 을 자동으로 바꾸는 기본 animation 을 추가할 수 있고, model viewer 와 마찬가지로 angle-per-second 로 이 값을 조정하고, camera-orbit 으로 초기 카메라 위치 또한 설정 가능하다.
Control Pannel 등도 toggle 가능하게 setting 하여 model viewer 와 흡사하지만 좀 더 많은 기능을 제공하는 3D Model Viewer 로 만들어 보았다.
Key Takeaways
- Google Model Viewer: 빠르고 쉬운 설정이 장점이나, 렌더링 모드와 커스터마이징에 한계가 있다.
- Three.js Custom Viewer: 유연성과 세밀한 제어가 강점이지만, 설정이 복잡하고 시간이 더 걸린다.
- 프로젝트 요구사항에 따라 간편함과 유연성 중 적절한 선택이 필요하다.
Conclusion
Google Model Viewer는 빠르고 간편하게 3D 모델을 웹에 삽입할 수 있는 훌륭한 도구다. 하지만 더 세밀한 제어나 다양한 렌더링 모드가 필요할 때는 Three.js를 사용한 커스텀 뷰어가 더 적합하다.
이번 작업에서 Three.js를 활용해 Diffuse, Mesh, Wireframe, Normal 등의 렌더링 모드를 지원하는 커스텀 뷰어를 구현했으며, 모델의 위치와 회전을 조정할 수 있는 패널도 추가했다. 이를 통해 사용자는 모델을 유연하게 조작하며 다양한 시각적 효과를 탐구할 수 있다.
앞으로 필요에 따라 기능을 확장할 수 있으며, 예를 들어 조명 설정, 카메라 뷰 저장 등을 추가할 수 있다. 이 글을 통해 3D 모델 뷰어의 구현 방법을 이해하고, 프로젝트에 맞는 최적의 방법을 선택하는 데 도움이 되길 바란다.
You may also like,