[바이브 코딩 3편] Three.js 카툰 렌더링 미니섬 만들기 (외곽선 끊어짐 해결)

[바이브 코딩 3편] Three.js 카툰 렌더링 미니섬 만들기 (외곽선 끊어짐 해결)

지난 시간에는 3차원 가상 공간에 마치 애니메이션 세계와 같은 예쁜 하늘 HDRI 배경을 적용했습니다.
이 공간 안에서 막막 하늘을 날고 싶지 않으신가요? ㅎㅎ

오늘은 이 가상 공간에 작은 섬을 하나 꾸며보려 하는데요.

안티 그래비티를 이용하여 계속해서 진행하도록 하겠습니다.

이전 대화 이어 하기

만약 안티그래비티 창을 닫았다가 새로 연 경우 대화창은 텅텅 비어 있을텐데요.

이 상태에서 다음 요청을 하면 프로젝트를 새로 분석하고 다시 진행하기는 하지만
그렇게 해서는 시간이 너무 많이 소요됩니다.
(단, 너무 대화가 길어진 경우는 컨텍스트가 커서 새로운 대화로 시작하는게 낫습니다)

그래서 지난 대화창을 다시 ‘소환’하는게 좋은데요,
그러면 지난 기억을 모두 간직한채로 AI가 다음 질문을 똘망똘망한 눈 🤩 으로 기다립니다!

화면 상단 우측의 시계 모양 아이콘은 지난 대화창들을 불러오는 버튼인데요.

클릭하면 이런 대화창 목록이 나옵니다.
영어로 이름을 지어주기 때문에 추정되는 거를 ( 그동안 아무것도 안하셨다면 맨 위 )
골라주시면,

이와 같이 대화목록이 다시 살아납니다.
지난번 그 시간 그 때로 돌아가 그 기억 그대로 대화할 준비가 된 것이지요!

미니섬 만들기

이제 아래와 같이 프롬프트를 입력하겠습니다.
한번에 성공할지는 모르겠지만 시행착오가 있다면 그 과정도 함께 보여드리겠습니다.

화면속 물체를 모두 지우고 동그란 섬 하나를 만들어줘. 섬은 애니메이션 형태로 카툰 랜더링을 적용해줘.

완료했다고 합니다.

그런데.. .’푸하하-‘ 섬이.. 섬이 너무 둥글어서 설데가 없네요 ㅎㅎ


그리고 섬을 천천히 회전하게 해놓았는데 그 부분도 빼야 할것 같습니다.
나무도 원하지 않는데 우선 뺄겁니다.

섬 표면이 둥글어서 설 자리가 없어. 평평하게 해줘. 그리고 나무는 지워줘

모양은 좀 나아졌습니다. 그런데 만화 스타일의 외곽선이 없네요.
만화 스타일의 외곽선을 ‘카툰 렌더링’이라고 하는데요.
카툰 랜더링은 꽤 고생해서 얻어내긴 했습니다.
아래와 같이 프롬프트를 입력해볼까요?

섬에 만화같은 외곽선이 없어. 카툰 렌더링을 적용해서 만화처럼 꾸며줘.

적용해 줬네요. 그런데 결정적인 문제가 있습니다.
마우스 휠을 굴려서 거리를 멀리하면 테두리가 끊어진듯 해서 너무 어색해지고, 또 너무 두껍습니다.

이 경우 해당 지점을 화면 캡쳐해서 붙여넣기(Ctrl + V) 하고 요청하는게 좋습니다.
참고로 윈도우의 캡쳐 단축키는 ‘윈도우키 + Shift + S’ 입니다!

그래 잘했는데 카메라 거리가 멀어지만 태투리가 두꺼워, 그리고 선이 중간에 끊어져.


이번꺼는 사실 꽤 난이도가 있는 기술 구현입니다.
결과를 보니 이런! 있던 테두리를 날려 먹었습니다…

테두리가 사라졌어. 코드를 검토해줘

이후로 계속 시도해봤지만… 좀처럼 성공을 못하더군요.

결국 성공햤던 코드의 일부를 가져와서 요청했습니다.

여전히 안 돼. 아래 코드를 분석해서 만들어줘, 작동하는 코드야.

/**
 * 임의의 THREE.Object3D 또는 Mesh에 카툰 렌더링 매트리얼과 외곽선을 강제로 일괄 적용합니다.
 * 버텍스 융합(Merge) 최적화 과정도 함께 수행합니다.
 * 
 * @param {THREE.Object3D} object - 적용할 대상 3D 객체 (Mesh, Group 등)
 * @param {Object} options - 아웃라인 및 렌더링 옵션 (scaleFactor, targetThickness, outlineColor, gradientMap)
 * @returns {Array<THREE.Mesh>} 적용이 완료된 메쉬 배열
 */
export function applyToonMaterial(object, options = {}) {
    const {
        scaleFactor = 1.0,
        targetThickness = 0.015,
        outlineColor = [0.05, 0.05, 0.05],
        gradientMap = getDefaultGradientMap()
    } = options;

    const outlineObjects = [];

    object.traverse((child) => {
        if (child.isMesh) {
            child.castShadow = true;
            child.receiveShadow = true;

            // 1: 쪼개진 버텍스를 병합(Merge)하고 노멀(Normal) 재계산
            let geometry = child.geometry;
            if (geometry) {
                try {
                    geometry = BufferGeometryUtils.mergeVertices(geometry, 1e-4);
                    geometry.computeVertexNormals();
                    child.geometry = geometry;
                } catch (e) {
                    console.warn('BufferGeometryUtils merge failed:', e);
                }
            }

            // 2: 카툰 렌더링 매트리얼로 덮어쓰기
            const oldMat = child.material;
            // 배열로 된 다중 재질일 경우 첫 번째 재질 기반, 아닐 경우 그대로 매핑
            const isArrayMat = Array.isArray(oldMat);
            const refMat = isArrayMat ? oldMat[0] : (oldMat || {});

            const toonMatOpts = {
                color: refMat.color || 0xffffff,
                gradientMap: gradientMap,
                side: THREE.FrontSide
            };

            if (refMat.map) {
                toonMatOpts.map = refMat.map;
            }

            const toonMat = new THREE.MeshToonMaterial(toonMatOpts);

            if (toonMat.map) toonMat.map.colorSpace = THREE.SRGBColorSpace;

            // 3: 외곽선 파라미터 셋업 (스케일 축소분 역산 포함)
            toonMat.userData.outlineParameters = {
                thickness: targetThickness / scaleFactor,
                color: outlineColor,
                alpha: 1.0,
                keepAlive: true
            };

            child.material = toonMat;
            outlineObjects.push(child);
        }
    });

    return outlineObjects;
}

다시 원상 복구되었습니다.

두께만 먼저 수정하겠습니다.

그림을 붙여넣고,

선의 두께가 너무 두꺼워 얇게 수정해줘

휴 이번엔 좀 나아진것 같습니다.

선이 약간 끊어지는 부분이 보이네요.

선이 끊어지지 않게 연결해줘.

나름 테두리가 모두 연결된 형태의 섬이 나왔습니다.
그런데 잔디밭 좌우 테두리가 상대적으로 얇습니다.

잔디밭 양쪽 테두리가 상대적으로 얇네. 수정해줘.

드.. 드디어! 깔끔한 테두리가 나왔네요.

가까이서 보든 멀리서 보든 뚜렷한 테두리가 보입니다.

우여곡절 끝에 애니메이션 섬이 완성되었습니다.
AI를 통해서도 이 코드를 얻기까지는 몇시간이 투자된것 같네요.
(비록 저는 일 시키고 쉬다가 틈틈히 봤지만요 ㅎㅎ)

AI가 다음번에 못 만들수도 있지 않을까 염려스러워 코드를 여기 싣습니다.
필요하신분 코드를 복사해서 자신만의 가상월드 스타터팩을 만들어보셔도 좋을것 같습니다 ^^

파일 구조

– anime_hdri.png

– index.html –

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 큐브 렌더링</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
    <script type="module" src="main.js"></script>
</body>
</html>

– main.js –

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { OutlineEffect } from 'three/addons/effects/OutlineEffect.js';
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';

// 1. 장면(Scene) 설정
const scene = new THREE.Scene();

// 텍스처 로더를 사용하여 생성된 HDRI PNG 이미지 로드
const textureLoader = new THREE.TextureLoader();
textureLoader.load('./anime_hdri.png', (texture) => {
    texture.mapping = THREE.EquirectangularReflectionMapping;
    // 최신 Three.js 에서는 올바른 색상 표현을 위해 colorSpace 설정을 권장합니다
    texture.colorSpace = THREE.SRGBColorSpace;

    // 360도 배경으로 설정
    scene.background = texture;
    // 환경 맵으로 설정하여 큐브의 메탈 재질에 반사되도록 함
    scene.environment = texture;
});

// 2. 카메라(Camera) 설정
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 5;

// 3. 렌더러(Renderer) 설정
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 고해상도 모니터 지원
document.body.appendChild(renderer.domElement);

// 카툰 외곽선 효과 적용 (OutlineEffect)
const effect = new OutlineEffect(renderer, {
    defaultThickness: 0.005, // 얇게 조정
    defaultColor: [0, 0, 0],
    defaultAlpha: 1.0,
    defaultKeepAlive: true
});

// 마우스로 방향을 조절하기 위한 OrbitControls 추가 (선택사항이지만 매우 유용함)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 부드러운 카메라 회전
controls.minDistance = 2; // 너무 가깝게 줌인되어 큐브 내부로 들어가는 것 방지
controls.maxDistance = 20; // 너무 멀리 줌아웃되어 객체가 너무 작아지는 것 방지

// 4. 조명(Light) 설정
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 기본 환경광
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); // 방향광(빛의 방향)
directionalLight.position.set(5, 5, 5);
scene.add(directionalLight);

// 5. 둥근 섬 객체 생성 (카툰 렌더링 적용)

// 카툰(Toon) 렌더링의 느낌을 살리기 위해 명암이 딱 끊어지게 만드는 맵(그라디언트 맵) 생성
function getDefaultGradientMap() {
    const canvas = document.createElement('canvas');
    canvas.width = 4;
    canvas.height = 1;
    const context = canvas.getContext('2d');
    context.fillStyle = '#555555'; // 어두운 그림자
    context.fillRect(0, 0, 1, 1);
    context.fillStyle = '#999999'; // 중간 톤
    context.fillRect(1, 0, 1, 1);
    context.fillStyle = '#ffffff'; // 밝은 면
    context.fillRect(2, 0, 2, 1);
    const texture = new THREE.CanvasTexture(canvas);
    // 픽셀이 뭉개지지 않고 선명한 경계를 가지도록 NearestFilter 적용
    texture.magFilter = THREE.NearestFilter;
    texture.minFilter = THREE.NearestFilter;
    return texture;
}

const toonMap = getDefaultGradientMap();

/**
 * 임의의 THREE.Object3D 또는 Mesh에 카툰 렌더링 매트리얼과 외곽선을 강제로 일괄 적용합니다.
 * 버텍스 융합(Merge) 최적화 과정도 함께 수행합니다.
 * 
 * @param {THREE.Object3D} object - 적용할 대상 3D 객체 (Mesh, Group 등)
 * @param {Object} options - 아웃라인 및 렌더링 옵션 (scaleFactor, targetThickness, outlineColor, gradientMap)
 * @returns {Array<THREE.Mesh>} 적용이 완료된 메쉬 배열
 */
function applyToonMaterial(object, options = {}) {
    const {
        scaleFactor = 1.0,
        targetThickness = 0.015,
        outlineColor = [0.0, 0.0, 0.0],
        gradientMap = getDefaultGradientMap()
    } = options;

    const outlineObjects = [];

    object.traverse((child) => {
        if (child.isMesh) {
            child.castShadow = true;
            child.receiveShadow = true;

            // 1: 쪼개진 버텍스를 병합(Merge)하고 노멀(Normal) 재계산 (끊어진 외곽선 문제 해결 핵심!)
            let geometry = child.geometry;
            if (geometry) {
                try {
                    geometry = geometry.clone();
                    // 원기둥 모델의 윗면과 옆면은 UV좌표(텍스처 위치)가 달라서, 
                    // mergeVertices가 같은 점으로 인식하지 않고 외곽선을 끊어버립니다.
                    // 현재는 단색 렌더링이므로 UV를 삭제한 뒤 하나로 병합하여 강제로 모서리를 이어붙입니다.
                    if (geometry.attributes.uv) geometry.deleteAttribute('uv');
                    if (geometry.attributes.normal) geometry.deleteAttribute('normal');

                    geometry = BufferGeometryUtils.mergeVertices(geometry, 1e-4);
                    geometry.computeVertexNormals();

                    // [수정] 잔디밭 모양(원기둥)에서 윗면의 넓은 면적 탓에 옆면 외곽선을 만드는 노멀 방향이 얇아지는 현상을 수동 교정합니다.
                    const posAttr = geometry.attributes.position;
                    const normAttr = geometry.attributes.normal;
                    for (let i = 0; i < normAttr.count; i++) {
                        let px = posAttr.getX(i);
                        let pz = posAttr.getZ(i);
                        let len = Math.sqrt(px * px + pz * pz);

                        // 중앙(원점 부근)이 아닌 바깥쪽 테두리 모서리 정점들 대상
                        if (len > 0.1) {
                            let nx = px / len;
                            let nz = pz / len;
                            let ny = normAttr.getY(i);

                            // Y방향(위아래) 노멀 비율을 X/Z(측면 좌우) 방면과 강제로 1:1로 매칭시켜 대각선 45도 방향으로 팽창하게 유도
                            // 이로써 상단 테두리와 측면 좌우 곡선의 외곽선 두께가 카메라 앵글에 상관없이 정확하게 동일해집니다.
                            if (ny > 0.05) ny = 1.0;
                            else if (ny < -0.05) ny = -1.0;
                            else ny = 0.0;

                            let nLen = Math.sqrt(nx * nx + ny * ny + nz * nz);
                            normAttr.setXYZ(i, nx / nLen, ny / nLen, nz / nLen);
                        }
                    }

                    child.geometry = geometry;
                } catch (e) {
                    console.warn('BufferGeometryUtils merge failed:', e);
                }
            }

            // 2: 카툰 렌더링 매트리얼로 덮어쓰기
            const oldMat = child.material;
            // 배열로 된 다중 재질일 경우 첫 번째 재질 기반, 아닐 경우 그대로 매핑
            const isArrayMat = Array.isArray(oldMat);
            const refMat = isArrayMat ? oldMat[0] : (oldMat || {});

            const toonMatOpts = {
                color: refMat.color || 0xffffff,
                gradientMap: gradientMap,
                side: THREE.FrontSide
            };

            if (refMat.map) {
                toonMatOpts.map = refMat.map;
            }

            const toonMat = new THREE.MeshToonMaterial(toonMatOpts);

            if (toonMat.map) toonMat.map.colorSpace = THREE.SRGBColorSpace;

            // 3: 외곽선 파라미터 셋업 (스케일 축소분 역산 및 OutlineEffect 호환 변수)
            toonMat.userData.outlineParameters = {
                thickness: targetThickness / scaleFactor,
                color: outlineColor,
                alpha: 1.0,
                keepAlive: true
            };

            child.material = toonMat;
            outlineObjects.push(child);
        }
    });

    return outlineObjects;
}

// 섬 그룹 생성
const islandGroup = new THREE.Group();

// 섬의 윗부분 (잔디가 있는 평평한 원기둥)
const grassGeometry = new THREE.CylinderGeometry(3, 3, 0.5, 64);
const grassMaterial = new THREE.MeshStandardMaterial({ color: 0x88dd44 }); // applyToonMaterial에서 변환됨
const grass = new THREE.Mesh(grassGeometry, grassMaterial);
grass.position.y = 0.25; // 흙 기둥 위에 얹히도록 위치 조정
islandGroup.add(grass);

// 섬의 아랫부분 (흙 기둥)
const dirtGeometry = new THREE.CylinderGeometry(3, 2, 1.5, 64);
const dirtMaterial = new THREE.MeshStandardMaterial({ color: 0x8b5a2b }); // applyToonMaterial에서 변환됨
const dirt = new THREE.Mesh(dirtGeometry, dirtMaterial);
dirt.position.y = -0.75; // 잔디 아래로 연결
islandGroup.add(dirt);

// 전체 섬의 위치를 중앙 화면에 맞게 약간 아래로 내림
islandGroup.position.y = -1;
scene.add(islandGroup);

// 제공된 카툰+외곽선 일괄 적용 함수 실행
applyToonMaterial(islandGroup, {
    scaleFactor: 1.0,
    targetThickness: 0.005, // 선 두께를 얇게 조정
    outlineColor: [0.0, 0.0, 0.0],
    gradientMap: toonMap
});

// 6. 애니메이션 루프
function animate() {
    requestAnimationFrame(animate);

    controls.update(); // Damping을 위해 매 프레임 업데이트
    effect.render(scene, camera); // OutlineEffect를 통한 화면 렌더링
}

// 창 크기 변경 시 카메라 및 렌더러 업데이트
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    effect.setSize(window.innerWidth, window.innerHeight);
});

// 애니메이션 시작
animate();

– style.css –

body {
    margin: 0;
    overflow: hidden;
    background-color: #000;
}

canvas {
    display: block;
}

위 코드를 그대로 구성하시면 애니메이션 풍의 3D웹이 작동합니다!

아무쪼록 즐거운 경험 되시길 바라며 이만!


– 오늘의 성경 말씀 –

오늘부터 성경 말씀을 한절씩 공유드리려 합니다.
여러분의 영혼과 삶에 생수가 되시길 바라는 마음으로 ^^

두려워하지 말라 내가 너와 함께 함이라 놀라지 말라 내가 네 하나님이 됨이라 내가 너를 굳세게 하리라 참으로 너를 도와 주리라 참으로 나의 의로운 오른손으로 너를 붙들리라.

– 이사야 41:10 –

댓글 남기기