본문 바로가기
나도 공부한다/삽질

[Three.js] gltf 파일 편집하기

by 꾸빵이 2024. 3. 11.

참고할 자료가 정말 없었기 때문에 고생을 많이 했다. 누군가에게 도움이 되길 바라며...

 

프로젝트에서 미로 3D 모델을 쓸 일이 생겼다.

여기서 미로 틀만 마음에 들었고, 벽 텍스쳐와 시작점 끝점의 깃발(?)을 수정하고 싶었다. 

 

내가 할 일은 두가지였다.

1. 깃발 삭제하기

2. 벽 텍스쳐 교체하기

 

 

 

참고로 내가 다운받은 gltf 파일의 json 데이터는 이렇다.

{
    "accessors": [
        {
            "bufferView": 2,
            "componentType": 5126,
            "count": 42,
            "max": [
                10.497600555419922, 10.497600555419922, 0.001500000013038516
            ],
            "min": [
                -10.497600555419922, -10.497600555419922, -0.44989699125289917
            ],
            "type": "VEC3"
        },
        {
            "bufferView": 2,
            "byteOffset": 504,
            "componentType": 5126,
            "count": 42,
            "max": [1.0, 1.0, 1.0],
            "min": [-1.0, -1.0, -1.0],
            "type": "VEC3"
        },
        {
            "bufferView": 1,
            "componentType": 5126,
            "count": 42,
            "max": [0.9911019802093506, 0.9972140192985535],
            "min": [0.0, 0.0],
            "type": "VEC2"
        },
        {
            "bufferView": 0,
            "componentType": 5125,
            "count": 96,
            "type": "SCALAR"
        },
        {
            "bufferView": 2,
            "byteOffset": 1008,
            "componentType": 5126,
            "count": 420,
            "max": [1.7300740480422974, 10.066490173339844, 5.522143840789795],
            "min": [-1.727004051208496, -10.051027297973633, 3.0],
            "type": "VEC3"
        },
        {
            "bufferView": 2,
            "byteOffset": 6048,
            "componentType": 5126,
            "count": 420,
            "max": [0.9749311804771423, 1.0, 1.0],
            "min": [-0.9749311804771423, -1.0, -0.04440061375498772],
            "type": "VEC3"
        },
        {
            "bufferView": 1,
            "byteOffset": 336,
            "componentType": 5126,
            "count": 420,
            "max": [0.947035014629364, 0.9627609848976135],
            "min": [0.001478999969549477, 0.001478999969549477],
            "type": "VEC2"
        },
        {
            "bufferView": 0,
            "byteOffset": 384,
            "componentType": 5125,
            "count": 1728,
            "type": "SCALAR"
        },
        {
            "bufferView": 2,
            "byteOffset": 11088,
            "componentType": 5126,
            "count": 1064,
            "max": [10.100000381469727, 10.100000381469727, 3.0],
            "min": [-10.100000381469727, -10.100000381469727, 0.0],
            "type": "VEC3"
        },
        {
            "bufferView": 2,
            "byteOffset": 23856,
            "componentType": 5126,
            "count": 1064,
            "max": [1.0, 1.0, 1.0],
            "min": [-1.0, -1.0, 0.0],
            "type": "VEC3"
        },
        {
            "bufferView": 1,
            "byteOffset": 3696,
            "componentType": 5126,
            "count": 1064,
            "max": [0.9968039989471436, 0.9987999796867371],
            "min": [0.0012000000569969416, 0.0012000000569969416],
            "type": "VEC2"
        },
        {
            "bufferView": 0,
            "byteOffset": 7296,
            "componentType": 5125,
            "count": 1908,
            "type": "SCALAR"
        }
    ],
    "asset": {
        "extras": {
            "author": "DerMische (https://sketchfab.com/dermische)",
            "license": "CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)",
            "source": "https://sketchfab.com/3d-models/labyrinth-3ec4e914ea8545188ab482e4b7e69c1c",
            "title": "Labyrinth"
        },
        "generator": "Sketchfab-15.1.0",
        "version": "2.0"
    },
    "bufferViews": [
        {
            "buffer": 0,
            "byteLength": 14928,
            "name": "floatBufferViews",
            "target": 34963
        },
        {
            "buffer": 0,
            "byteLength": 12208,
            "byteOffset": 14928,
            "byteStride": 8,
            "name": "floatBufferViews",
            "target": 34962
        },
        {
            "buffer": 0,
            "byteLength": 36624,
            "byteOffset": 27136,
            "byteStride": 12,
            "name": "floatBufferViews",
            "target": 34962
        }
    ],
    "buffers": [
        {
            "byteLength": 63760,
            "uri": "scene.bin"
        }
    ],
    "extensionsUsed": ["KHR_materials_unlit"],
    "images": [
        {
            "uri": "textures/ground_baseColor.jpeg"
        },
        {
            "uri": "textures/signs_baseColor.jpeg"
        },
        {
            "uri": "textures/Stylized_Grass_003_basecolor.jpeg"
        }
    ],
    "materials": [
        {
            "doubleSided": true,
            "extensions": {
                "KHR_materials_unlit": {}
            },
            "name": "ground",
            "pbrMetallicRoughness": {
                "baseColorTexture": {
                    "index": 0
                },
                "metallicFactor": 0.0
            }
        },
        {
            "doubleSided": true,
            "extensions": {
                "KHR_materials_unlit": {}
            },
            "name": "signs",
            "pbrMetallicRoughness": {
                "baseColorTexture": {
                    "index": 1
                },
                "metallicFactor": 0.0
            }
        },
        {
            "doubleSided": true,
            "extensions": {
                "KHR_materials_unlit": {}
            },
            "name": "walls",
            "pbrMetallicRoughness": {
                "baseColorTexture": {
                    "index": 2
                },
                "metallicFactor": 0.0
            }
        }
    ],
    "meshes": [
        {
            "name": "Object_0",
            "primitives": [
                {
                    "attributes": {
                        "NORMAL": 1,
                        "POSITION": 0,
                        "TEXCOORD_0": 2
                    },
                    "indices": 3,
                    "material": 0,
                    "mode": 4
                }
            ]
        },
        {
            "name": "Object_1",
            "primitives": [
                {
                    "attributes": {
                        "NORMAL": 5,
                        "POSITION": 4,
                        "TEXCOORD_0": 6
                    },
                    "indices": 7,
                    "material": 1,
                    "mode": 4
                }
            ]
        },
        {
            "name": "Object_2",
            "primitives": [
                {
                    "attributes": {
                        "NORMAL": 9,
                        "POSITION": 8,
                        "TEXCOORD_0": 10
                    },
                    "indices": 11,
                    "material": 2,
                    "mode": 4
                }
            ]
        }
    ],
    "nodes": [
        {
            "children": [1],
            "matrix": [
                1.0, 0.0, 0.0, 0.0, 0.0, 2.220446049250313e-16, -1.0, 0.0, 0.0,
                1.0, 2.220446049250313e-16, 0.0, 0.0, 0.0, 0.0, 1.0
            ],
            "name": "Sketchfab_model"
        },
        {
            "children": [2, 3, 4],
            "name": "3cb89465cf9b46a2ad919ed17325b25d.obj.cleaner.materialmerger.gles"
        },
        {
            "mesh": 0,
            "name": "Object_2"
        },
        {
            "mesh": 1,
            "name": "Object_3"
        },
        {
            "mesh": 2,
            "name": "Object_4"
        }
    ],
    "samplers": [
        {
            "magFilter": 9729,
            "minFilter": 9987,
            "wrapS": 10497,
            "wrapT": 10497
        }
    ],
    "scene": 0,
    "scenes": [
        {
            "name": "Sketchfab_Scene",
            "nodes": [0]
        }
    ],
    "textures": [
        {
            "sampler": 0,
            "source": 0
        },
        {
            "sampler": 0,
            "source": 1
        },
        {
            "sampler": 0,
            "source": 2
        }
    ]
}

 

 

1차 시도

json 코드를 보는데, 해당 깃발이 어떤 이름을 갖고 있는지는 모르겠어서 우선 signs_baseColor라는 이름의 텍스쳐를 사용한다는걸 파악했다. 이걸 이용하여 materials 배열에서 signs라는 이름의 재질이 signs_baseColor 텍스처를 사용하고 있음을 알 수 있었고 이 재질은 meshes 배열에서 Object_1이라는 메쉬에 사용된다는걸 알아냈다.

 

그래서 Object_1을 삭제하는 코드를 짰다. 그런데 삭제되지 않았다...

gltfLoader.load(
    '../static/img/scene.gltf',
    (gltf) => {
        console.log("성공");
        scene.add(gltf.scene);

        // 'Object_1' 메시를 찾아 제거합니다.
        const objectToRemove = gltf.scene.getObjectByName('Object_1');
        console.log(objectToRemove);
        if (objectToRemove) {
            // 'Object_1'이 포함된 상위 노드에서 메시를 제거합니다.
            objectToRemove.parent.remove(objectToRemove);

            // 메모리 정리
            if (objectToRemove.geometry) objectToRemove.geometry.dispose();
            if (objectToRemove.material) {
                if (Array.isArray(objectToRemove.material)) {
                    objectToRemove.material.forEach(material => {
                        if (material.map) material.map.dispose();
                        material.dispose();
                    });
                } else {
                    if (objectToRemove.material.map) objectToRemove.material.map.dispose();
                    objectToRemove.material.dispose();
                }
            }
        };
    },
    (process) => {
        console.log("진행중");
        console.log(process);
    },
    (error) => {
        console.log("에러남");
        console.log(error);
    }
);

 

 

2차 시도

결국 json 데이터에서 의심가는 코드를 전부 삭제하려고 했다. 그런데 구조가 복잡했고 요소들이 연결되어있어 하나의 요소가 삭제되면 다른 요소에 영향을 줄 수 있는 위험이 있었다.

 

 

3차 시도

블렌더를 이용하여 지웠다. 객체 삭제는 확실히 블렌더로 하는 게 편하다는걸 깨달았다... 

방법은 이렇다. 블렌더를 설치하고 File - Import - glTF를 누르고 내가 다운 받은 3D 에셋을 불러온다.

 

우측 상단을 보면 모델이 어떤 객체들로 이루어졌는지 나와있다. 삭제하려는 객체를 클릭하고 delete를 누르면 끝이다. 참고로 블렌더에서 Collection에 Cube라는걸 자동으로 생성해주는데 이것도 지워줘야한다. 원본에는 없는거다. 나는 기존 텍스쳐도 삭제했다.

 

 

이제 File - Export - glTF 2.0을 누르고 원하는 폴더에 gltf 형식으로 저장한다. 여기서 주의해야할 점은 Format이 gltf로 되어있는지 반드시 확인해야한다.

 

 

 

이제 vscode로 넘어와서 GLTFLoader로 파일을 불러오면 끝이다.

 

다음으로는 walls에 텍스쳐를 입혀야한다.

여기서 정말 많이 헤맸다. 나름 이론 공부를 꽤 했다고 생각했는데, 이렇게 gltf를 코드로 직접 편집하는 건 아직 보지못했기 때문이다.

그래서 콘솔창에 찍힌 객체 그룹을 다 뜯어보기도 했고 json 파일을 건드려보기도 했다. 콘솔창에서 child를 모두 확인하며 walls를 찾아보려 했는데 쉽지 않았고, 찾았다고 해도 해당 material을 바꿀 방법이 떠오르지 않았다.

 

많은 시도 끝에 다음과 같은 결론을 내렸다.

  1. 모델의 모든 자식을 순회하며, 'walls'라는 이름을 가진 재질을 찾는다.
  2. 해당 재질에 새로 로드한 텍스쳐를 적용하고 needsUpdate 속성을 true로 설정하여 재질이 업데이트 되도록 한다.
  3. 최종적으로 수정된 모델을 씬에 추가하고 렌더링한다.
// 텍스쳐 로드
const textureLoader = new THREE.TextureLoader();
const wallsTexture = textureLoader.load('../static/img/textures/Stylized_Grass_003_basecolor.jpeg');

const gltfLoader=new GLTFLoader();

gltfLoader.load(
    '../static/img/untitled2.gltf',
    (gltf)=>{
        console.log("성공")
        console.log(gltf.scene)
        gltf.scene.traverse((child) => {
            if (child.isMesh && child.material && child.material.name === 'walls') {
                child.material.map = wallsTexture;
                child.material.needsUpdate = true;
            }
        });
        scene.add(gltf.scene)

    },
    (process)=>{
        console.log("진행중")
        console.log(process)
    },
    (error)=>{
        console.log("에러남")
        console.log(error);
    }

)

 

 

traverse 메서드가 뭔지 몰랐기 때문에 더 많이 삽질했다. JS에서 트리 순회를 하는 함수인 것 같은데 거의 사용하지 않는듯하고, Three.js에서는 씬의 모든 자식 객체 목록을 반환하는 역할이라고 한다. 유용할 것 같으니 꼭 알아둬야겠다.

 

 

traverse 함수

Three.js에서 traverse 함수는 주어진 객체(여기서는 GLTF 모델의 씬)와 그 모든 자식 객체를 재귀적으로 순회하는 데 사용된다. 이 함수는 씬 그래프 내의 모든 객체에 대해 특정 작업을 수행할 수 있게 해준다. 함수는 인자로 콜백 함수를 받으며, 이 콜백은 순회하는 각 객체에 대해 호출된다.

traverse 함수를 사용하는 주된 이유는 씬의 모든 객체를 일일이 수동으로 접근하는 것이 아니라, 자동으로 모든 객체에 접근하여 특정 작업을 수행하기 위함이다.

 

isMesh 속성

isMesh는 Three.js에서 특정 객체가 Mesh 타입인지 여부를 나타내는 boolean 속성이다. Mesh는 3D 모델에서 보이는 실제 형태를 나타내는 객체로, 지오메트리(형태)와 재질(표면 처리)을 포함한다.

객체가 Mesh 타입일 경우, 해당 객체는 3D 공간에서 렌더링되는 형태를 가진다는 것을 의미한다. 이 속성을 확인함으로써, 순회하는 객체가 3D 형태를 가진 '메시'인지 아니면 다른 타입의 객체(ex) 광원, 카메라 등)인지 판별할 수 있다.

 

needsUpdate

true 값을 주면 Three.js가 재질의 변경 사항을 감지하고 다음 렌더링 사이클에서 이를 반영한다.

 

 

이렇게 텍스쳐 변경도 끝이 났다.

그런데 내가 원하던 모습이 아니다. 분명 작고 자잘자잘한 잔디 텍스쳐를 입혔는데, 확대되어 대나무 크기로 변해버렸다.

반복을 통해 해결해보겠다.

 

 

코드를 추가해줬다.

wallsTexture.repeat.set(8,8);
wallsTexture.wrapS=THREE.RepeatWrapping;
wallsTexture.wrapT=THREE.RepeatWrapping;

 

 

훨씬 나아졌다!! 하지만 여전히 뭔가 밋밋한 느낌이 든다. 현재는 baseColor만 입혀줬는데, height, normal, roughness, ambientOcclusion도 입혀줄 것이다.

 

 

 

나머지 텍스쳐를 로드해주고 적용한다.

// 텍스쳐 로드
const textureLoader = new THREE.TextureLoader();
const wallsBaseColor = textureLoader.load('../static/img/textures/Stylized_Grass_003_basecolor.jpeg');
const wallsNormalColor=textureLoader.load('../static/img/textures/Stylized_Grass_003_normal.jpg');
const wallsHeightColor=textureLoader.load('../static/img/textures/Stylized_Grass_003_height.png');
const wallsRoughColor=textureLoader.load('../static/img/textures/Stylized_Grass_003_roughness.jpg');
const wallsAmbientColor=textureLoader.load('../static/img/textures/Stylized_Grass_003_ambientOcclusion.jpg')

wallsBaseColor.repeat.set(8,8);
wallsBaseColor.wrapS=THREE.RepeatWrapping;
wallsBaseColor.wrapT=THREE.RepeatWrapping;

const gltfLoader=new GLTFLoader();


gltfLoader.load(
    '../static/img/untitled2.gltf',
    (gltf)=>{
        console.log("성공")
        console.log(gltf.scene)
        gltf.scene.traverse((child) => {
            if (child.isMesh && child.material && child.material.name === 'walls') {
                child.material.map = wallsBaseColor;
                child.material.normalMap=wallsNormalColor;
                child.material.displaceMap=wallsHeightColor;
                child.material.roughnessMap=wallsRoughColor;
                child.material.aoMap=wallsAmbientColor;
                child.material.needsUpdate = true;
            }
        });
        scene.add(gltf.scene)

    },
    (process)=>{
        console.log("진행중")
        console.log(process)
    },
    (error)=>{
        console.log("에러남")
        console.log(error);
    }

)

 

 

 

음... 구나 육면체 같은 물체에서는 확실히 입체적인 느낌이 있었는데 그냥 벽이라서 그런지 큰 변화를 못느꼈다. 아마 벽을 이루고 있는 세그먼트가 충분하지 않아서 그런 것 같다.

 

 

 

아무튼 원하는 대로 gltf 파일을 성공적으로 편집했다. 다만 객체를 삭제할 때는 코드가 아닌 블렌더를 이용한게 아쉬웠다.

 

이 글을 읽는 분에게 꼭 하고 싶은 말이 있는데, 코드에 변화를 줬는데도 화면에 이전 결과물이 계속 뜬다면 ctrl+F5 (윈도우 기준)을 눌러 캐시를 비워주면 된다. 이걸 몰라서 내 코드에 문제가 있는줄 알고 많은 시간을 허비했다....