Introduction

하는 일이 3D Domain 에 편중되어 있다보니, 사내에서 보고를 위한 간단한 데모를 제작할 때나, 혹은 논문 project page 를 만드는 등 web page 에서 3D Model 을 rendering 해서 보여줄 일이 가끔 있다.

웹에 대해 잘 모르고 간단히 쓸만한 방법으론

  1. Google Model Viewer 사용하거나
  2. 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 하는 듯한 효과를 낼 수 있다.

img alt
Figure: Gradient Map

위 이미지를 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 = &lt;style&gt; :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; } &lt;/style&gt; &lt;div class=&quot;controls&quot;&gt; &lt;div id=&#39;meta&#39;&gt; &lt;button id=&quot;textureBtn&quot;&gt;Diffuse&lt;/button&gt; &lt;button id=&quot;meshBtn&quot;&gt;Geometry&lt;/button&gt; &lt;button id=&quot;normalBtn&quot;&gt;Normal&lt;/button&gt; &lt;button id=&quot;wireframeBtn&quot;&gt;Wireframe&lt;/button&gt; &lt;button id=&quot;autoRotateBtn&quot;&gt;Auto-Rotate&lt;/button&gt; &lt;button id=&quot;toonShadingBtn&quot;&gt;Toon Shading&lt;/button&gt; &lt;button id=&quot;setBgBtn1&quot;&gt;Env1&lt;/button&gt; &lt;button id=&quot;setBgBtn2&quot;&gt;Env2&lt;/button&gt; &lt;button id=&quot;setBgBtn3&quot;&gt;Env3&lt;/button&gt; &lt;button id=&quot;removeBgBtn&quot;&gt;Remove Env&lt;/button&gt; &lt;div id=&quot;modelInfo&quot; style=&#39;padding-left: 0.1rem; font-size:0.8rem&#39;&gt;&lt;strong&gt;[Model Info]&lt;/strong&gt; loading...&lt;/div&gt; &lt;/div&gt; &lt;div id=&quot;transform-container&quot; style=&quot;position: relative;&quot;&gt; &lt;div class=&quot;transform-panel&quot;&gt; &lt;button id=&quot;togglePanelBtn&quot;&gt;&lt;i class=&quot;bi bi-caret-left&quot;&gt;&lt;/i&gt;&lt;/button&gt; &lt;div id=&quot;transformControls&quot; style=&quot;display: block;&quot;&gt; &lt;label&gt;Position X: &lt;input type=&quot;number&quot; id=&quot;posX&quot; step=&quot;0.1&quot; value=&quot;0&quot;&gt;&lt;/label&gt; &lt;label&gt;Position Y: &lt;input type=&quot;number&quot; id=&quot;posY&quot; step=&quot;0.1&quot; value=&quot;0&quot;&gt;&lt;/label&gt; &lt;label&gt;Position Z: &lt;input type=&quot;number&quot; id=&quot;posZ&quot; step=&quot;0.1&quot; value=&quot;0&quot;&gt;&lt;/label&gt; &lt;label&gt;Rotation X (deg): &lt;input type=&quot;number&quot; id=&quot;rotX&quot; step=&quot;1&quot; value=&quot;0&quot;&gt;&lt;/label&gt; &lt;label&gt;Rotation Y (deg): &lt;input type=&quot;number&quot; id=&quot;rotY&quot; step=&quot;1&quot; value=&quot;0&quot;&gt;&lt;/label&gt; &lt;label&gt;Rotation Z (deg): &lt;input type=&quot;number&quot; id=&quot;rotZ&quot; step=&quot;1&quot; value=&quot;0&quot;&gt;&lt;/label&gt; &lt;div&gt;Scale: &lt;input type=&quot;range&quot; id=&quot;scale&quot; style=&quot;width: 7rem;&quot; min=&quot;1&quot; max=&quot;20&quot; step=&quot;0.1&quot; value=&quot;8&quot;&gt;&lt;/div&gt; &lt;div&gt;Roughness: &lt;input type=&quot;range&quot; id=&quot;roughness&quot; style=&quot;width: 7rem;&quot; min=&quot;0&quot; max=&quot;1&quot; step=&quot;0.01&quot; value=&quot;0.5&quot;&gt;&lt;/div&gt; &lt;div&gt;Metalness: &lt;input type=&quot;range&quot; id=&quot;metalness&quot; style=&quot;width: 7rem;&quot; min=&quot;0&quot; max=&quot;1&quot; step=&quot;0.01&quot; value=&quot;0.5&quot;&gt;&lt;/div&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt; &lt;div id=&quot;canvas-container&quot; style=&#39;text-align: center&#39;&gt; &lt;div id=&quot;loadingProgressBar&quot;&gt;&lt;/div&gt; &lt;/div&gt; ;

    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(&#39;#canvas-container&#39;).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(&#39;change&#39;, () => this.updateControlPanel()); 

    this.textureLoader = new THREE.TextureLoader();
    this.whiteTexture = this.textureLoader.load(&#39;https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/white.jpg&#39;);
    this.whiteTexture.mapping = THREE.EquirectangularReflectionMapping;

    this.gradTexture = this.textureLoader.load(&#39;https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/gradient.jpg&#39;);
    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 [&#39;src&#39;, &#39;auto-rotate&#39;, &#39;angle-per-second&#39;, &#39;camera-orbit&#39;, &#39;hide-control-ui&#39;];
}

attributeChangedCallback(name, oldValue, newValue) {
    if (name === &#39;src&#39; && newValue) {
        this.loadModel(newValue);
    } else if (name === &#39;auto-rotate&#39;) {
        this.autoRotate = newValue !== null;
    } else if (name === &#39;angle-per-second&#39;) {
        this.anglePerSecond = parseFloat(newValue) || 30;
    } else if (name === &#39;camera-orbit&#39;) {
        this.setCameraOrbit(newValue);
    } else if (name === &#39;hide-control-ui&#39;) {
        const controlsDiv = this.shadowRoot.querySelector(&#39;.controls&#39;);
        if (newValue !== null) {
            controlsDiv.style.display = &#39;none&#39;;
        } else {
            controlsDiv.style.display = &#39;block&#39;;
        }
    }
}

initEventListeners() {
    this.shadowRoot.querySelector(&#39;#textureBtn&#39;).addEventListener(&#39;click&#39;, () => this.showTexture());
    this.shadowRoot.querySelector(&#39;#meshBtn&#39;).addEventListener(&#39;click&#39;, () => this.showMesh());
    this.shadowRoot.querySelector(&#39;#wireframeBtn&#39;).addEventListener(&#39;click&#39;, () => this.showWireframe());
    this.shadowRoot.querySelector(&#39;#normalBtn&#39;).addEventListener(&#39;click&#39;, () => this.showNormal());
    this.shadowRoot.querySelector(&#39;#setBgBtn1&#39;).addEventListener(&#39;click&#39;, () => this.setBackground1());
    this.shadowRoot.querySelector(&#39;#setBgBtn2&#39;).addEventListener(&#39;click&#39;, () => this.setBackground2());
    this.shadowRoot.querySelector(&#39;#setBgBtn3&#39;).addEventListener(&#39;click&#39;, () => this.setBackground3());
    this.shadowRoot.querySelector(&#39;#removeBgBtn&#39;).addEventListener(&#39;click&#39;, () => this.removeBG());

    this.shadowRoot.querySelector(&#39;#posX&#39;).addEventListener(&#39;input&#39;, () => this.updateModelTransform());
    this.shadowRoot.querySelector(&#39;#posY&#39;).addEventListener(&#39;input&#39;, () => this.updateModelTransform());
    this.shadowRoot.querySelector(&#39;#posZ&#39;).addEventListener(&#39;input&#39;, () => this.updateModelTransform());
    this.shadowRoot.querySelector(&#39;#rotX&#39;).addEventListener(&#39;input&#39;, () => this.updateModelTransform());
    this.shadowRoot.querySelector(&#39;#rotY&#39;).addEventListener(&#39;input&#39;, () => this.updateModelTransform());
    this.shadowRoot.querySelector(&#39;#rotZ&#39;).addEventListener(&#39;input&#39;, () => this.updateModelTransform());

    this.shadowRoot.querySelector(&#39;#roughness&#39;).addEventListener(&#39;input&#39;, () => this.updateMaterialProperties());
    this.shadowRoot.querySelector(&#39;#metalness&#39;).addEventListener(&#39;input&#39;, () => this.updateMaterialProperties());

    this.shadowRoot.querySelector(&#39;#scale&#39;).addEventListener(&#39;input&#39;, (e) => {
        this.modelSize = parseFloat(e.target.value);
        if (this.model) this.model.scale.set(this.modelSize, this.modelSize, this.modelSize);
    });

    this.shadowRoot.querySelector(&#39;#autoRotateBtn&#39;).addEventListener(&#39;click&#39;, () => {
        this.autoRotate = !this.autoRotate;
        this.shadowRoot.querySelector(&#39;#autoRotateBtn&#39;).textContent = this.autoRotate ? &#39;Auto-Rotate On&#39; : &#39;Auto-Rotate Off&#39;;
    });

    this.shadowRoot.querySelector(&#39;#togglePanelBtn&#39;).addEventListener(&#39;click&#39;, () => {
        const controls = this.shadowRoot.querySelector(&#39;#transformControls&#39;);
        const button = this.shadowRoot.querySelector(&#39;#togglePanelBtn&#39;);
        if (controls.style.display === &#39;none&#39;) {
            controls.style.display = &#39;block&#39;;
            button.innerHTML = &#39;&lt;i class=&quot;bi bi-caret-left-fill&quot;&gt;&lt;/i&gt;&#39;;
        } else {
            controls.style.display = &#39;none&#39;;
            button.innerHTML = &#39;&lt;i class=&quot;bi bi-caret-left&quot;&gt;&lt;/i&gt;&#39;;
        }
    });

    this.shadowRoot.querySelector(&#39;#toonShadingBtn&#39;).addEventListener(&#39;click&#39;, () => { 
        this.toonEnabled = !this.toonEnabled;
        if (this.toonEnabled) {
            this.enableToonShading();
            this.shadowRoot.querySelector(&#39;#toonShadingBtn&#39;).textContent = &#39;Toon Shading Off&#39;;
        } else {
            this.disableToonShading();
            this.shadowRoot.querySelector(&#39;#toonShadingBtn&#39;).textContent = &#39;Toon Shading On&#39;;
        }
    });
}

updateControlPanel() { 
    if (this.model) {
        this.shadowRoot.querySelector(&#39;#posX&#39;).value = this.model.position.x.toFixed(1);
        this.shadowRoot.querySelector(&#39;#posY&#39;).value = this.model.position.y.toFixed(1);
        this.shadowRoot.querySelector(&#39;#posZ&#39;).value = this.model.position.z.toFixed(1);

        this.shadowRoot.querySelector(&#39;#rotX&#39;).value = THREE.MathUtils.radToDeg(this.model.rotation.x).toFixed(0);
        this.shadowRoot.querySelector(&#39;#rotY&#39;).value = THREE.MathUtils.radToDeg(this.model.rotation.y).toFixed(0);
        this.shadowRoot.querySelector(&#39;#rotZ&#39;).value = THREE.MathUtils.radToDeg(this.model.rotation.z).toFixed(0);
    }
}

updateModelTransform() {
    if (this.model) {
        const posX = parseFloat(this.shadowRoot.querySelector(&#39;#posX&#39;).value);
        const posY = parseFloat(this.shadowRoot.querySelector(&#39;#posY&#39;).value);
        const posZ = parseFloat(this.shadowRoot.querySelector(&#39;#posZ&#39;).value);
        this.model.position.set(posX, posY, posZ);

        const rotX = THREE.MathUtils.degToRad(parseFloat(this.shadowRoot.querySelector(&#39;#rotX&#39;).value));
        const rotY = THREE.MathUtils.degToRad(parseFloat(this.shadowRoot.querySelector(&#39;#rotY&#39;).value));
        const rotZ = THREE.MathUtils.degToRad(parseFloat(this.shadowRoot.querySelector(&#39;#rotZ&#39;).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(&#39;#roughness&#39;);
            const metalnessInput = this.shadowRoot.querySelector(&#39;#metalness&#39;);
            if (roughnessInput) roughnessInput.value = initialRoughness;
            if (metalnessInput) metalnessInput.value = initialMetalness;
        }
        this.renderer.render(this.scene, this.camera);
    }, undefined, (err) => {
        console.error(&#39;Skybox err:&#39;, err);
        alert(&#39;Cannot load Skybox Image&#39;);
    });
}

setBackground1() {
    const url = &#39;https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/spruit_sunrise_1k_HDR.hdr&#39;;
    const rgbeLoader = new RGBELoader();
    this.set_bg(url, rgbeLoader);
}

setBackground2() {
    const url = &#39;https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/aircraft_workshop_01_1k.hdr&#39;;
    const rgbeLoader = new RGBELoader();
    this.set_bg(url, rgbeLoader);
}

setBackground3() {
    const url = &#39;https://huggingface.co/spaces/hhhwan/custom_gs/resolve/main/glbs/lebombo_1k.hdr&#39;;
    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(&#39; &#39;).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(&#39;#loadingProgressBar&#39;);
    progressBar.style.display = &#39;block&#39;; 
    progressBar.style.width = &#39;0%&#39;

    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(&#39;camera-orbit&#39;);
        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(&#39;#roughness&#39;);
                        const metalnessInput = this.shadowRoot.querySelector(&#39;#metalness&#39;);
                        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(&#39;#roughness&#39;).value = initialRoughness;
        this.shadowRoot.querySelector(&#39;#metalness&#39;).value = initialMetalness;

        this.shadowRoot.querySelector(&#39;#modelInfo&#39;).innerHTML = `&lt;strong&gt;[Model Info]&lt;/strong&gt; Vertices: ${vertexCount}, Faces: ${faceCount}`;
        this.scene.add(this.model);

        this.showTexture();
        this.updateControlPanel(); 

        progressBar.style.display = &#39;none&#39;; // 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(&#39;Loading Error:&#39;, error);
    });
}

updateMaterialProperties() { 
    const roughnessValue = parseFloat(this.shadowRoot.querySelector(&#39;#roughness&#39;).value);
    const metalnessValue = parseFloat(this.shadowRoot.querySelector(&#39;#metalness&#39;).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(&#39;#meta&#39;);
    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(&#39;resize&#39;, () => {
        const viewers = document.querySelectorAll(&#39;simple-model-viewer&#39;);
        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,