diff --git a/readme.md b/readme.md index 9a88c1c9e8b83c4e53bc600db4b0bc725e2f3123..3c2e2434cdedd0b0a38c0ebff2359eec542d6149 100644 --- a/readme.md +++ b/readme.md @@ -1,1794 +1,135 @@ -# CG Course Final Project +# CG Course Final Project: Light Shaft Tutorial ## 소개 * **학과**: 소프트웨어학과 * **학번**: 201820749 * **이름**: 한수현 -## 목차 -**1장.** 주제 및 목표 -**2장**. 기본 장면 렌더링 -**3장**. 카메라와 GUI 조작 -**4장**. 렌더 타겟 -**5장**. 가중치 추출 -**6장**. 라이트 셰프트 -**7장**. 꾸미기 -**8장**. Source & Referrence - -## 안내 -**Visual Studio 2022**의 **node.js 프로젝트**에서 개발되었습니다. -실행은 반드시 **Three.js 패키지가 설치된 서버 환경**에서 해주세요. -로컬 환경에서 실행하면 브라우저 보안상의 이유로 로컬 파일을 불러올 수 없습니다(*텍스처*, *모델* 등). - -<mark>본 문서는 교수님의 요청에 따라 **'친근한 강의'** 느낌으로 제작되었음을 알립니다.</mark> - -## 1장: 주제 및 목표 - -### 1.1 주제 -제가 선정한 주제는 **라이트 셰프트(Light Shaft) 효과**로, 오브젝트 사이로 스며들어오는 빛 줄기의 구현입니다. -기존 Three.js 예제에서 수정한 결과물은 아니지만 비슷한 예제로 **Godrays**가 있습니다. -다만 **Godrays** 예제의 경우 셰이더 패스가 많고 과정이 복잡해서 필요없는 부분(*FakeSun 패스*, *Combine 패스*)을 빼고, 셰이더 코드도 더 쉽게 새로 만들었습니다. -세부적인 원리에 관한 설명은 2장부터 시작하는 실습에서부터 천천히 설명하도록 하겠습니다. - -### 1.2 목표 -**1.** 기본적인 렌더링 장면 구현하기 -**2.** OrbitControls를 사용한 카메라의 움직임 구현하기 -**3.** 렌더 타겟과 영상 처리에 대해 이해하기 -**4.** EffectComposer와 ShaderPass 사용법 이해하기 -**5.** GLSL 프로그래밍 과정 및 문법 이해하기 -**6.** 깊이 텍스처로부터 가중치 추출하기 -**7.** 라이트 셰프트 셰이더 만들기 - -## 2장: 기본 장면 렌더링 - - -<details> - -<summary><b>2장 튜토리얼 (펼치기/접기)</b></summary> - -자, 이제 본격적으로 `script.js` 파일을 만들어 봅시다. -여기서 기본적인 장면이라 함은, 대충 바닥 있고 나무 몇 개 있고 라이트 좀 있는 그런 장면을 말하는 겁니다. -대부분이 수업에 나온 내용이므로 가볍게 따라와주시면 되겠습니다. -빈 `script.js` 파일을 준비해주세요. -참고로 `index.html` 파일은 너무 간단하므로 설명하지 않겠습니다. - -### 2.1 모듈 -가장 먼저 해야할 것은 역시 모듈 불러오기입니다. -``` -import * as THREE from 'three'; -import { Vector3, Color } from 'three'; -import Stats from './jsm/libs/stats.module.js'; -import { OBJLoader } from './jsm/loaders/OBJLoader.js'; -``` -이렇게 맨 위에 추가해주세요. -첫째 줄은 Three.js의 모든 **코어 라이브러리**를 불러옵니다. -둘째 줄은 프로그램에서 사용할 **클래스**들입니다. -각각 3차원 벡터와 색상에 해당됩니다. -셋째 줄은 **스탯 모듈**인데요. -수업에서 배우지는 않았지만 이걸 사용하면 결과물의 왼쪽 상단에 프레임, 루프 소요 시간, 메모리 등의 정보를 보여줍니다. -필수는 아니지만 만들고 있는 프로그램의 사양을 파악할 수 있으므로 넣어봅시다. -넷째 줄은 **오브젝트 로더**입니다. -이걸 이용해서 기본 모델 중 하나인 `tree.obj` 파일을 불러올 계획입니다. - -### 2.2 상수 -다음으로 할 일은 프로그램에서 사용할 **상수**를 설정하는 겁니다. -C언어로 치자면 `#define` 같은 것이죠. -예상 외로 많지만 일단 따라 쳐보도록 합시다. -``` -const constants = { - defaultCameraPosition: new Vector3(0, 20, 200), // 카메라의 기본 위치 - defaultCameraLookAt: new Vector3(0, 70, 0), // 카메라가 바라보는 기본 위치 - defaultFieldOfView: 60, // 카메라의 기본 시야각 - defaultBackgroundColor: new Color(0xcccccc), // 렌더러의 기본 바탕색 - defaultAmbientLightColor: new Color(0x555555), // 앰비언트 라이트의 기본 색 - defaultDirectionalLightColor: new Color(0x555555), // 디렉셔널 라이트의 기본 색 - defaultSunObjectGeometry: new THREE.SphereGeometry(1, 24, 12), // 태양 오브젝트의 기본 지오메트리 - defaultSunObjectMaterial: new THREE.MeshBasicMaterial({ color: 0xffffff }), // 태양 오브젝트의 기본 재질 - maxSunDistance: 600, // 태양 오브젝트의 최대 거리 - treeCount: 10, // 나무의 개수 - treeOffset: 50 // 나무들의 간격 - -}; -``` -하나하나 설명하기엔 간단해서 주석으로 대신합니다. -기본 장면이지만 대부분의 상수가 기본 값이기 때문에 많이 들어가는군요. - -### 2.3 실행 함수 -다음은 이제 실행 함수의 차례입니다. -``` -function run() { - const scene = new THREE.Scene(); - const stats = new Stats(); - const container = document.getElementById('container'); - container.appendChild(stats.dom); - - let renderer, camera; - let ambientLight, directionalLight; - let sunObject; -} -``` -간단하게 이렇게만 추가해봅시다. - -``` -const scene = new THREE.Scene(); -const stats = new Stats(); -const container = document.getElementById('container'); -container.appendChild(stats.dom); -``` -먼저 **씬**과 **스탯**, **컨테이너**를 생성합니다. -컨테이너의 경우 `index.html`의 div로 선언된 영역 `container`에 해당됩니다. - -``` -let renderer, camera; -let ambientLight, directionalLight; -let sunObject; -``` -그리고 **렌더러**, **카메라**, **앰비언트 라이트**, **디렉셔널 라이트**, **태양 오브젝트**를 선언해둡니다. -지금 당장 생성하지 않을 오브젝트들이므로 `let`으로 선언합니다. - -### 2.4 렌더러 -이제 **렌더러**를 생성할 시간입니다. -`run()` 함수에 그대로 쓰면 지저분해지므로 함수로 따로 빼도록 합시다. -하지만 전역이 아닌 `run()` 함수의 내부에 정의해주세요. -``` -function createRenderer() { - renderer = new THREE.WebGLRenderer(); - renderer.setSize(window.innerWidth, window.innerHeight); - renderer.setClearColor(constants.defaultBackgroundColor, 1.0); - - container.appendChild(renderer.domElement); -} -``` -렌더러를 생성하고 몇 가지의 설정을 합니다. - -``` -renderer = new THREE.WebGLRenderer(); -``` -생성은 `THREE.WebGLRenderer()` 생성자를 호출하면 됩니다. - -``` -renderer.setSize(window.innerWidth, window.innerHeight); // 영역 크기 설정 -renderer.setClearColor(constants.defaultBackgroundColor, 1); // 바탕색 설정 -``` -그리고 픽셀 비율, 영역 크기, 바탕색을 설정합니다. -영역 크기는 창의 내부 크기로 각각 설정해주면 됩니다. -바탕색의 경우 앞서 정의했던 `constants`의 렌더러 기본 바탕색을 사용해주도록 합시다. -뒤의 `1` 은 알파 값으로서, 바탕색이 투명하면 안 되므로 `1`로 해줍니다. - -``` -container.appendChild(renderer.domElement); -``` -마지막으로 컨테이너에 추가해주면 렌더러는 준비 완료입니다! - -### 2.5 카메라 -**카메라**를 생성해봅시다. -마찬가지로 함수로 분리합니다. - -``` -function createCamera() { - camera = new THREE.PerspectiveCamera(constants.defaultFieldOfView, window.innerWidth / window.innerHeight, 1, 1000); - camera.position.copy(constants.defaultCameraPosition); - camera.lookAt(constants.defaultCameraLookAt); - scene.add(camera); -} -``` -렌더러와 마찬가지로 생성 후 값을 좀 수정하고 부모 오브젝트에 추가합니다. - -``` -camera = new THREE.PerspectiveCamera(constants.defaultFieldOfView, window.innerWidth / window.innerHeight, 1, 1000); -``` -카메라는 **Perspective** 카메라로 준비합니다. -수업을 열심히 들으셨다면 각각의 인자가 무엇을 뜻하는지 아시겠죠? -시야각, 종횡비, near, far입니다. -시야각의 경우 역시 `constants`를 사용합니다. - -``` -camera.position.copy(constants.defaultCameraPosition); -camera.lookAt(constants.defaultCameraLookAt); -``` -그리고 카메라 위치와 타겟 위치를 설정합니다. -`constants`의 값을 보시면 아시겠지만 약간 위를 바라보는 각도입니다. - -``` -scene.add(camera); -``` -마지막으로 씬에 넣어줍니다. - -### 2.6 라이트 -**라이트**를 생성해봅시다. - -``` -function createLights() { - ambientLight = new THREE.AmbientLight(constants.defaultAmbientLightColor); - scene.add(ambientLight); - - directionalLight = new THREE.DirectionalLight(constants.defaultDirectionalLightColor); - directionalLight.position.set(0, constants.maxSunDistance, -constants.maxSunDistance); - scene.add(directionalLight); -} -``` -전체 함수는 이렇습니다. - -``` -ambientLight = new THREE.AmbientLight(constants.defaultAmbientLightColor); -scene.add(ambientLight); -``` -앰비언트 라이트의 경우 생성할 때 색상만 지정해주면 끝입니다. -위치의 영향을 받지 않는 라이트니까요. -색상은 `constants`의 기본 색상을 이용하고 생성 후 바로 씬에 넣습니다. - -``` -directionalLight = new THREE.DirectionalLight(constants.defaultDirectionalLightColor); -directionalLight.position.set(0, constants.maxSunDistance, -constants.maxSunDistance); -scene.add(directionalLight); -``` -디렉셔널 라이트도 생성은 앰비언트 라이트와 같습니다. -하지만 이 친구는 위치를 지정해줘야합니다. -앞서 정의했던 `constants`에서 최대 거리 값을 넣어줍니다. -씬에 넣는것도 잊지 마시고요. - -### 2.7 오브젝트 -마지막으로 **오브젝트** 배치입니다. -만들어야 할 오브젝트는 총 세 종류인데요. -각각 바닥, 태양, 나무입니다. - -``` -function createObjects() { - const loader = new OBJLoader(); - const whiteMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff }); - - const bottomPlaneGeometry = new THREE.PlaneGeometry(1000, 1000); - const bottomPlaneMesh = new THREE.Mesh(bottomPlaneGeometry, whiteMaterial); - bottomPlaneMesh.rotation.x = THREE.MathUtils.degToRad(-90); - scene.add(bottomPlaneMesh); - - sunObject = new THREE.Mesh( - constants.defaultSunObjectGeometry, - constants.defaultSunObjectMaterial); - sunObject.position.copy(directionalLight.position); - sunObject.scale.multiplyScalar(40); - scene.add(sunObject); - - for (let i = 0; i < constants.treeCount; ++i) { - loader.load('models/obj/tree.obj', function (tree) { - tree.material = whiteMaterial; - tree.position.set( - (i * constants.treeOffset) - (constants.treeOffset * (constants.treeCount / 2) - (constants.treeOffset / 2)), - 0, - (Math.random() - 0.5) * 2 * 100); - tree.rotation.y = Math.random() * Math.PI; - tree.scale.multiplyScalar(100); - scene.add(tree); - }); - } -} -``` -조금 길지만 하나하나 살펴봅시다. - -``` -const loader = new OBJLoader(); -const whiteMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff }); -``` -먼저 `loader`라는 오브젝트 로더를 생성합니다. -`import`할 때 추가한 거 기억하시죠? -그리고 흰색 재질을 생성하는데요, `THREE.MeshLambertMaterial()` 함수를 이용합니다. -**램버트 재질**로 해야 빛을 받으니까요. -색상도 흰색이니 `0xffffff`로 설정합니다. - -``` -const bottomPlaneGeometry = new THREE.PlaneGeometry(1000, 1000); -const bottomPlaneMesh = new THREE.Mesh(bottomPlaneGeometry, whiteMaterial); -bottomPlaneMesh.rotation.x = THREE.MathUtils.degToRad(-90); -scene.add(bottomPlaneMesh); -``` -바닥 오브젝트는 흰색 재질을 이용할 것이므로 지오메트리만 만들면 됩니다. -`PlaneGeometry()` 함수를 이용해 크기가 `1000x1000`인 **평면 지오메트리**를 생성합니다. -그리고 나서 지오메트리와 재질을 이용해 평면 오브젝트를 만듭니다. -만들고 나면 기본적으로 바닥에 깔린 모양이 아니라 **`z`의 양방향**을 바라보는 서 있는 모습이 됩니다. -따라서 `x`축으로 `-90`도 돌려줘야 합니다(`90`도를 생각했다면 오른손/왼손 좌표계를 공부하고 오세요!). -돌릴 때에는 **라디안 단위**를 사용하므로 `THREE.MathUtils.degToRad()` 함수를 사용해야 합니다. -그리고 씬에 넣어주면 바닥 완성입니다. - -``` -sunObject = new THREE.Mesh( - constants.defaultSunObjectGeometry, - constants.defaultSunObjectMaterial); -sunObject.position.copy(directionalLight.position); -sunObject.scale.multiplyScalar(40); -scene.add(sunObject); -``` -태양 오브젝트는 `constants`에서 정의한 기본 지오메트리 및 재질을 사용합니다. -보시면 램버트가 아닌 **베이직 재질**임을 알 수 있습니다. -이유는 당연히 태양이 디렉셔널 라이트의 영향을 받으면 어두워지기 때문입니다. -따라서 태양을 항상 원하는 색으로 지정하기 위해 베이직 재질을 사용합니다. -위치는 디렉셔널 라이트와 같은 위치로 합니다(**태양 = 디렉셔널 라이트**). -크기는 너무 작으면 안 되므로 `x`, `y`, `z` 모든 요소에 `40`을 곱해줍니다. -그리고 씬에 넣어주면 태양 오브젝트 완성입니다. - -``` -for (let i = 0; i < constants.treeCount; ++i) { - loader.load('models/tree.obj', function (tree) { - tree.material = whiteMaterial; - tree.position.set( - (i * constants.treeOffset) - (constants.treeOffset * (constants.treeCount / 2) - (constants.treeOffset / 2)), - 0, - (Math.random() - 0.5) * 2 * 100); - tree.rotation.y = Math.random() * Math.PI * 2; - tree.scale.multiplyScalar(100); - scene.add(tree); - }); -} -``` -나무는 **스마트**하게 `for`문을 이용해서 만들어줍니다. 반복 수는 `constants`에서 설정한 나무 개수입니다. -먼저 오브젝트 파일을 로드해야 하는데요, 먼저 `load()` 함수에 대해 알아봅시다. -`load()` 함수는 4개의 인자를 받는데요. - -1. 모델 파일의 경로 -1. 로드 되었을 때 콜백되는 함수 -1. 로드 과정 중 콜백되는 함수 -1. 로드 실패 시 콜백되는 함수 - -이렇게 구성됩니다. -우리는 1번과 2번만 사용하면 되므로 3번 4번은 무시하셔도 됩니다. -2번 함수의 경우 인자로 `tree`를 받죠? 이게 생성된 오브젝트입니다. -그런데 우리는 흰색 재질을 나무에 적용시키고 싶으므로 함수 내부에서 값을 수정해줍니다. -위치는 x의 경우 **스마트**하게 머릿속으로 계산해낸 공식을 사용하시면 되고요. -`y`는 그대로 `0`, `z`는 `-100~100` 범위의 랜덤 값을 지정해줍니다(`Math.random()` 함수는 `-1~1` 범위의 랜덤 값을 반환합니다). -각도도 `y`축으로 `0~360`도 범위의 랜덤 값으로 지정합니다. -크기는 `100`을 곱해줍니다. -이후 씬에 추가하면 나무도 끝! - -이제 방금까지 만들었던 생성 함수들을 `let` 선언 바로 아래에 쭉 호출해줍니다(**작성한 순서대로**). - -### 2.8 업데이트 & 렌더 함수 -그리고 실행을 위해 마지막으로 해줘야 할 것이 있죠. 바로 `animate()` 함수입니다. -매 프레임마다 호출되는 그거요. - -``` -function animate() { - requestAnimationFrame(animate); - - update(); - render(); - - function update() { - stats.update(); - } - function render() { - renderer.render(scene, camera); - } -} -``` -보시면 두 함수로 갈라져 있습니다. -`update()` 함수와 `render()` 함수인데요. -`update()` 함수는 매 프레임마다 실행할 **계산**을 담당합니다(돌아가는 오브젝트 만들때 각도 값을 더해주는 그런 계산입니다). -`render()` 함수는 매 프레임마다 **렌더**하는 것만 담당합니다. - -``` -function update() { - stats.update(); -} -``` -업데이트 함수에서 스탯의 업데이트를 호출해줍시다. -그래야 스탯이 매 프레임마다 정보를 보여줄 수 있습니다. - -``` -function render() { - renderer.render(scene, camera); -} -``` -렌더 함수에서는 장면을 렌더합니다. -간단하죠? - -`run()` 함수에서 생성 함수 호출 부분 바로 아래에 `animate()` 함수를 호출해주도록 합시다. - -### 2.9 정리 및 실행 -거의 다 됐습니다! -이제 **크기 조절 함수**만 만들면 실행해볼 수 있습니다. - -``` -function onWindowResize() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - - renderer.setSize(window.innerWidth, window.innerHeight); -} -``` -크기가 바뀌면 콜백되는 함수입니다. -먼저 카메라 종횡비를 다시 설정하고 프로젝션 행렬을 재계산합니다. -렌더러는 렌더 영역의 크기를 새로 업데이트합니다. - -``` -window.addEventListener('resize', onWindowResize); -``` -그리고 이 호출 구문을 `animate()` 함수 호출 아래에 놓아줍시다. -이러면 화면 크기가 바뀌어도 정상적으로 크기가 업데이트되어 제대로 렌더링할 수 있습니다. - -마지막으로 스크립트 맨 아래에 `run()` 함수를 호출해줍니다. -결과물이 잘 보인다면 제대로 하신겁니다. **짝짝!** - -</details> - -<mark>2장의 전체 스크립트는 [**여기**](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/blob/main/tutorial_data/scripts/script_2.js "Light shaft chapter 2 source code")에 있습니다.</mark> - -## 3장: 카메라와 GUI 조작 - - -<details> - -<summary><b>3장 튜토리얼 (펼치기/접기)</b></summary> - -3장에서는 **카메라 조작**을 구현하고, **GUI**를 추가해서 세부적인 사항을 조절할 수 있도록 할 겁니다. -사실 카메라의 경우 Three.js에서 `OrbitControls`라는 ~~아주그냥날로먹는~~ 모듈을 제공해주기 때문에 굉장히 짧고 쉬울 예정입니다. -`OrbitControls`에 대해 간단히 설명드리자면, 생성해서 카메라만 넣어주면 마우스로 카메라를 아주 손쉽게 조작할 수 있게 해주는 모듈입니다. -피봇(LookAt 위치)을 중심으로 카메라를 이동시키므로 `OrbitControls`라는 이름이 붙은 듯 합니다. - -### 3.1 모듈 -먼저 2장에서 추가한 모듈 리스트에 `GUI`와 `OrbitControls` 모듈을 추가해 줍시다. - -``` -import { GUI } from './jsm/libs/lil-gui.module.min.js'; -import { OrbitControls } from './jsm/controls/OrbitControls.js'; -``` -이렇게요. - -### 3.2 상수와 GUI 변수 -오르빗 컨트롤에 필요한 상수도 조금 추가해보도록 하겠습니다. - -``` -const constants = { - ~ - orbitControlsMinDistance: 50, - orbitControlsMaxDistance: 500, - ~ - defaultSunColor: new Color(0xffffff), - ~ - -}; -``` -이 셋을 아무 곳에나 추가해줍니다. -먼저 `orbitControlsMinDistance`는 최대한 가까이 갈 수 있는 카메라와 피봇 간의 거리입니다. -`orbitControlsMaxDistance`는 반대로 최대한 멀리 갈 수 있는 카메라와 피봇 간의 거리입니다. -`defaultSunColor`는 이름만 봐도 알 수 있듯 태양 오브젝트의 색상입니다(**빛 색상과 혼동하지 말 것!**). - -다음으로 해야할 일은 GUI에 사용할 변수를 등록하는 겁니다. -`constants`와 모습이 꽤 비슷합니다. - -``` -const controls = { - backgroundColor: constants.defaultBackgroundColor, //렌더러의 바탕색 - ambientLightColor: constants.defaultAmbientLightColor, // 앰비언트 라이트의 색상 - sunLightColor: constants.defaultDirectionalLightColor, // 디렉셔널 라이트의 색상 - sunLightIntensity: 1, // 디렉셔널 라이트의 강도 - sunColor: constants.defaultSunColor, // 태양 오브젝트의 색상 - sunPositionX: 0, // 태양 오브젝트의 x 위치 - sunPositionY: constants.maxSunDistance, // 태양 오브젝트의 y 위치 - sunPositionZ: -constants.maxSunDistance, // 태양 오브젝트의 z 위치 - freeMove: false, // OrbitControls 사용 여부 - fieldOfView: 60 // 카메라의 시야각 - -}; -``` -나열해보니까 상당히 많네요. -하지만 미리 많이 만들어두면 좋습니다. -디렉셔널 라이트의 이름이 `sun`으로 바뀐 이유는 글자 수가 짧기 때문입니다. -여기에 사용한 이름은 실제 `GUI`에서 보여질 이름이기 때문에, `directional`로 했다간 범위 밖으로 튀어나갈 수도 있습니다. -각각이 무엇을 뜻하는지는 주석을 참고하세요. - -### 3.3 오르빗 컨트롤 생성 -먼저 오르빗 컨트롤을 먼저 생성하고 GUI를 만들도록 하겠습니다. -가장 먼저 해야할 것은 역시 선언이겠죠? - -``` -let orbitControls; -``` -2장에서 만든 `let` 친구들 사이에 끼워넣어 줍니다. - -이제 **인스턴스**를 생성해봅시다. -2장에서 했던 것처럼 생성 함수를 하나 만들어줍니다. - -``` -function createOrbitControls() { - orbitControls = new OrbitControls(camera, renderer.domElement); - orbitControls.minDistance = constants.orbitControlMinDistance; - orbitControls.maxDistance = constants.orbitControlMaxDistance; - orbitControls.enabled = false; - - camera.lookAt(constants.defaultCameraLookAt); -} -``` -생성 시 카메라와 렌더러를 받습니다. -생성한 후에는 방금 전 `constants`에 추가한 두 상수를 각각의 최소 거리와 최대 거리에 대입해줍니다. -`enabled`는 오르빗 컨트롤을 사용할지 여부인데 저는 기본 값으로 `false`를 주었습니다. -시작했을 땐 위를 바라보게 하기 위함입니다. -여기서 중요한 부분이 있는데, 오르빗 컨트롤을 활성화하면 카메라는 반드시 오르빗 컨트롤에서 지정한 별도의 LookAt의 위치를 바라보게 됩니다. -즉, 우리는 기본 값으로 `(0, 70, 0)`을 지정했지만 오르빗 컨트롤을 생성했기 때문에 카메라의 LookAt 값은 자동으로 `(0, 0, 0)`으로 바뀌어버렸습니다. -그러므로 카메라의 LookAt 위치를 다시 기본 값으로 수정합니다. - -추가로 `createCamera()` 함수 내에서 LookAt 위치를 설정하는 구분은 지우셔도 됩니다. - -### 3.4 GUI 생성 -이제 **GUI**를 만들 차례입니다. - -``` -const gui = new GUI(); - -const generalFolder = gui.addFolder('General'); -const lightFolder = gui.addFolder('Light'); -const cameraFolder = gui.addFolder('Camera'); -``` -GUI는 인자 없이 생성할 수 있습니다. -먼저 폴더를 만들어야 하는데, 저는 세 부분으로 나눴습니다. - -그 다음엔 원하는 항목을 추가합니다. -일단 렌더러의 배경색으로 해보죠. 라이트와는 관계 없으므로 `General` 항목에 넣겠습니다. - -``` -generalFolder.addColor(controls, 'backgroundColor').listen().onChange(function (value) { - renderer.setClearColor(value, 1); -}); -``` -뭔가 복잡하죠? -세 가지의 명령을 한 번에 실행해서 그렇습니다. 한 번 나눠보죠. - -``` -const category = generalFolder.addColor(controls, 'backgroundColor'); -``` -이건 `General` 폴더에 색상을 설정할 수 있는 항목을 넣겠다는 뜻입니다. -인자로 `controls`와 `'backgroundColor'`를 넣은 것은 `controls.backgroundColor` 값이 여기에 영향을 받을 것이라는 의미입니다. -이름을 꼭 똑같이 넣어주셔야 에러가 나지 않습니다! - -``` -category.listen(); -``` -이것은 `controls.backgroundColor` 값이 GUI 조작 없이 바뀌었을 때 GUI 디스플레이에 바뀐 값을 표시해주겠다는 뜻입니다. -이걸 안 하시면 바뀌어도 GUI 디스플레이엔 바뀌기 전의 값이 계속 표시됩니다. - -``` -category.onChange(function (value) { - renderer.setClearColor(value, 1); -}); -``` -이건 GUI 조작으로 인해 값이 바뀌었을 때 콜백되는 함수를 설정하는 겁니다. -바뀐 값이 `value`로서 들어오게 됩니다. -그 값을 이용해 배경색을 바꾸면 되겠죠? - -이런 식으로 세 가지의 함수 호출을 한 줄로 줄인 것이 저겁니다. -저렇게 하지 않으면 `category`처럼 오브젝트를 하나씩 만들어줘야하고, 줄 수도 늘어납니다. - -이제 더 추가해볼까요? - -``` -lightFolder.addColor(controls, 'ambientLightColor').listen().onChange(function (value) { - ambientLight.color = value; -}); -lightFolder.addColor(controls, 'sunLightColor').listen().onChange(function (value) { - directionalLight.color = value; -}); -lightFolder.add(controls, 'sunLightIntensity', 0, 1).listen().onChange(function (value) { - directionalLight.intensity = value; -}); -lightFolder.addColor(controls, 'sunColor').listen().onChange(function (value) { - sunObject.material.color = value; -}) -lightFolder.add(controls, 'sunPositionX', -constants.maxSunDistance, constants.maxSunDistance).listen().onChange(function (value) { - directionalLight.position.x = value; - sunObject.position.x = value; -}); -lightFolder.add(controls, 'sunPositionY', -constants.maxSunDistance, constants.maxSunDistance).listen().onChange(function (value) { - directionalLight.position.y = value; - sunObject.position.y = value; -}); -lightFolder.add(controls, 'sunPositionZ', -constants.maxSunDistance, constants.maxSunDistance).listen().onChange(function (value) { - directionalLight.position.z = value; - sunObject.position.z = value; -}); -``` -대부분이 `Light` 폴더에 들어갑니다. -색상은 해봤으니 알겠고, 그럼 슬라이더는 어떻게 만드는지 알아봅시다. - -``` -lightFolder.add(controls, 'sunLightIntensity', 0, 1).listen().onChange(function (value) { - directionalLight.intensity = value; -}); -``` -일단 함수명이 `addColor`에서 `add`로 바뀌었습니다. 인자도 2개 늘어났네요. -인자는 각각 최솟값과 최댓값입니다. 이 경우 `0~1` 범위가 되겠군요. -나머지는 색상하고 같습니다. - -이제 나머지 항목들도 어떤 원리인지 아시겠죠? -`sunPositionX/Y/Z`의 경우 디렉셔널 라이트와 태양 오브젝트는 꼭 붙어다녀야 하는 친구들이기에 같이 변경해주었습니다. - -마지막으로 카메라만 하면 끝입니다. - -``` -cameraFolder.add(controls, 'freeMove').listen().onChange(function (value) { - camera.position.copy(constants.defaultCameraPosition); - - if (value) { - camera.lookAt(0, 0, 0); - } else { - camera.lookAt(constants.defaultCameraLookAt); - } - - orbitControls.enabled = value; -}); -cameraFolder.add(controls, 'fieldOfView', 40, 80).listen().onChange(function (value) { - camera.fov = value; - camera.updateProjectionMatrix(); -}); -``` -카메라는 항목이 두 개입니다. -먼저 `freeMove`인데요. 이걸 활성화하면 오르빗 컨트롤이 활성화됩니다. -활성화라고 하니 당연히 체크박스겠죠? 체크박스는 슬라이더에서 최솟값과 최댓값 자리를 비우면 만들 수 있습니다. -내용을 보면 일단 `true/false` 상관 없이 카메라의 위치를 기본 위치로 바꿉니다. -그리고 `true`라면 LookAt 위치를 원점으로, `false`라면 기본 위치로 바꿉니다. -끝으로 오르빗 컨트롤의 `enabled` 값을 `value`로 설정해주면 끝! - -시야각의 경우 `40~80` 범위를 갖는 슬라이더이며 카메라의 `fov` 값 수정 후 `updateProjectionMatrix()` 함수를 호출해줍니다. -이걸 해 주지 않으면 바꿔도 변화가 없으므로 주의하세요. - -이제 GUI도 끝났습니다. -실행해보면 GUI를 조작해 카메라 및 조명 등 많은 것을 조절할 수 있는 것을 확인할 수 있습니다. -결과물이 잘 보인다면 제대로 하신겁니다. **짝짝짝!** - -</details> - -<mark>3장의 전체 스크립트는 [**여기**](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/blob/main/tutorial_data/scripts/script_3.js "Light shaft chapter 3 source code")에 있습니다.</mark> - -## 4장: 렌더 타겟 - - -<details> - -<summary><b>4장 튜토리얼 (펼치기/접기)</b></summary> - -### 4.1 렌더 타겟이란? -이제부터 약간의 원리 이해가 필요합니다. -먼저 **렌더 타겟**에 대해 설명드리도록 하겠습니다. - -렌더 타겟이란 렌더링의 대상을 말합니다. 즉, 정육면체를 렌더했으면 그것은 렌더 타겟이라는 버퍼에 그려지게 됩니다. -근데 생각해보니 우린 이제까지 렌더 타겟을 지정해준 적이 없죠? 화면에는 잘 나오던데, 왜 그럴까요? -답은 **기본 렌더 타겟**이 있기 때문입니다. 렌더 타겟에는 **기본 렌더 타겟**이 있고 **사용자 지정 렌더 타겟**이 있는데요. -우리는 지금까지 별도로 렌더 타겟을 만든 적이 없으니 그대로 기본 렌더 타겟에 그려지고 있던 겁니다. - -하지만 지금부터는 별도의 렌더 타겟이 필요합니다. -추가적인 렌더 타겟이 왜 필요한지도 알아야겠죠? 답은 **영상 처리의 원리**에 있습니다. -영상 처리란 오브젝트를 전부 그린 버퍼를 **후처리(Post-processing)**하는 작업을 말합니다. -예를 들어 장면 전체를 흑백 화면으로 모니터에 출력하려면 어떻게 하면 될까요? -모든 오브젝트의 재질 색을 흑백으로 바꾸는 건 현명하지 않겠죠? -그럼 그냥 컬러로 모든 오브젝트를 그리고 이후 그 렌더 타겟을 따로 빼서 흑백 처리를 해주면 어떨까요. -그럼 더 효율적이겠죠? 이것이 영상 처리 기법입니다. - -라이트 셰프트를 구현하기 위해서는 장면의 모든 오브젝트가 그려진 렌더 타겟에서, 오브젝트가 없는 부분을 추출해야만 합니다. -왜인지는 복잡하므로 지금 설명하지 않겠습니다. 6장까지 보시면 알 수 있습니다. -따라서 우리는 새로운 렌더 타겟을 하나 만들고 거기에 모든 오브젝트를 그려야 합니다. - -### 4.2 렌더 타겟 생성 -먼저 선언부터 해볼까요. - -``` -let originalRT; -``` -이름은 원래 장면을 담을 것이므로 `originalRT`로 했습니다. -`RT`는 당연히 `RenderTarget`의 약자입니다. - -다음은 생성입니다. -생성 방법은 그리 거창하지 않습니다. -이전처럼 함수를 따로 만들어 줍시다. - -``` -function createRenderTargets() { - originalRT = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight); - originalRT.depthTexture = new THREE.DepthTexture(); - originalRT.depthTexture.format = THREE.DepthFormat; - originalRT.depthTexture.type = THREE.UnsignedShortType; -} -``` -생성 함수에 크기만 넣어주면 끝입니다. 하지만 아래에 뭔가 더 있죠? -바로 **깊이(depth) 텍스처**입니다. 원래 안 넣으면 `null`로 설정되어 제공해주지 않지만, 우린 이게 꼭 필요합니다. -이유는 역시 복잡하므로 일단 넘어갑니다. 5장에서 사용할 거예요. -깊이 텍스처를 생성했으면 텍스처의 형식을 지정해줘야 합니다. -깊이를 저장할 것이므로 형식은 `THREE.DepthFormat`으로 해줍니다. -타입은 버퍼의 자료형을 말합니다. 버퍼는 하나의 배열이므로 한 칸의 크기를 지정해줘야 합니다. -하지만 깊이는 별 거 없죠? 본래 `0~1` 범위의 값이므로 2바이트면 충분할 겁니다. 따라서 `THREE.UnsignedShortType`을 지정해줍니다. - -렌더 타겟이 완성되었습니다! -이 함수를 호출하는 것을 잊지 마세요. `createLights()` 함수 바로 아래에서 호출해주시면 됩니다. -그럼 이제 장면을 여기에 렌더하도록 바꿔봅시다. - -### 4.3 렌더 타겟 변경 -변경하는 방법도 엄청 쉽습니다. -`animate()` 함수의 `render()` 함수로 가봅시다. - -``` -function render() { - renderer.setRenderTarget(originalRT); - renderer.render(scene, camera); - renderer.setRenderTarget(null); -} -``` -`render()` 함수 위 아래에 `setRenderTarget()` 함수를 호출해주면 됩니다. -인자는 각각 `originalRT`와 `null`인데요. -`originalRT`는 방금 만든 렌더 타겟이고, `null`은 기본 렌더 타겟입니다(알아서 그렇게 인식합니다). -즉, `originalRT`에 렌더하고 렌더 후 기본 렌더 타겟으로 다시 되돌려놓는 겁니다. -되돌려 놓는 것이 필수는 아니지만 그렇게 해놓으면 깔끔하고 좋습니다. - -### 4.4 자동 크기 조절 -렌더 타겟은 크기가 정해진 텍스처와 같습니다. -그렇다는 말은 창의 크기가 변경되면 렌더 타겟의 크기도 업데이트해줘야 한다는 겁니다. -`onWindowResize()` 함수를 조금만 수정해봅시다. - -``` -function onWindowResize() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - - renderer.setSize(window.innerWidth, window.innerHeight); - originalRT.setSize(window.innerWidth, window.innerHeight); -} -``` -맨 아래에 `originalRT`의 크기를 업데이트하도록 해주면 됩니다. - -그럼 이제 실행해볼까요? - -네, 하나도 안 보이면 **정상**입니다! -우린 방금 기본 렌더 타겟인 화면이 아닌 `originalRT`에 렌더링 했으니까요. -궁금하면 `render()` 함수에서 `originalRT`로 렌더 타겟을 변경하는 줄을 주석 처리해보세요. - -다시 잘 보이죠? -결과물이 잘 ~~안~~보인다면 제대로 하신겁니다. **짝짝짝짝!** - -</details> - -<mark>4장의 전체 스크립트는 [**여기**](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/blob/main/tutorial_data/scripts/script_4.js "Light shaft chapter 4 source code")에 있습니다.</mark> - -## 5장: 가중치 추출 - - -<details> - -<summary><b>5장 튜토리얼 (펼치기/접기)</b></summary> - -5장부터는 꽤 어려워지기 시작합니다. -아마 모르거나 처음 보는 내용이 많을 거예요. -일단 모듈부터 추가해보죠. - -### 5.1 모듈 -이 프로그램에 추가될 마지막 모듈 둘입니다. - -``` -import { EffectComposer } from './jsm/postprocessing/EffectComposer.js'; -import { ShaderPass } from './jsm/postprocessing/ShaderPass.js'; -``` -첫 번재 것은 **이펙트 컴포저**라고하는 것인데요. 한 번에 여러 **패스**를 사용할 수 있도록 해줍니다. -여기서 잠깐, 패스부터 설명하겠습니다. 패스란 한 번의 **렌더링**을 뜻합니다. -예를 들어 4장에서 봤던 흑백 효과 적용을 예로 들어봅시다. -이 경우 전체 장면을 `originalRT`같은 곳에 그리는 **1번 패스**가 있습니다. -그 다음엔 `originalRT`를 요리조리 바꿔서 흑백으로 만드는 **2번 패스**가 있겠죠. -이펙트 컴포저는 여기서 1번 패스 2번 패스를 이펙트 컴포저에 **등록한 순으로 실행**해주는 친구입니다. -사실 여러 패스가 아니라 단일 패스더라도 이펙트 컴포저가 없으면 `ShaderPass`를 렌더하기 어렵습니다. - -두 번째 것은 **셰이더 패스**입니다. 정확히는 영상 처리용 패스라고 보는 것이 좋습니다. -여기서 또 다른 의문점이 해결됩니다. 위에서 언급한 흑백 효과를 잘 적용했다고 칩시다. -그럼 그 `originalRT`에 흑백이 적용된 내용을 기본 렌더 타겟에 제대로 렌더해줘야 하지 않겠어요? -하지만 어떻게 하죠? `originalRT`는 하나의 평면입니다. 삼각형 두 개가 만나있는 그 간단한 평면입니다. -이걸 어떻게 해야 카메라 면전에 딱 맞게 들이밀 수 있을까요? -바로 **화면에 정렬된 평면(Screen-aligned quad)**를 이용하는 겁니다. -그 전에 먼저 **프로젝션 공간**을 이해해야 합니다. 사실 이해라 할 것도 없어요. - - - -그냥 직접 그려보았습니다. -그림을 보면 아시다시피 프로젝션 공간은 왼쪽과 아래쪽 끝이 `-1`이고, 오른쪽과 위쪽 끝이 `1`입니다. -그리고 버텍스 셰이더에서는 기본적으로 **월드 공간**에 있는 버텍스를 **프로젝션 공간**으로 변환시키죠. -그럼 그냥 월드 공간의 위치가 `-1~1`로 된 평면을 버텍스 셰이더에서 뷰 행렬 및 프로젝션 행렬을 곱하지 않고 다이렉트로 넘기면 되는 거 아닐까요? -그럼 월드 공간에서 `-1~1`이었던 버텍스가 프로젝션 공간에서도 그대로 `-1~1`이게 되고, 이건 화면에 꼭 맞는 평면인 거죠! -예... 그래서 그걸 직접 하실 필요는 없고요. 우리 믿음직한 셰이더 패스 친구가 다 해준답니다. -참고로 그림에 있는 **UV 공간**도 기억해두시기 바랍니다. 얘는 `-1`이 아니라 `0`부터 시작합니다. 6장에서 분명 다시 보게 될 거예요. - -### 5.2 가중치 렌더 타겟 생성 -모듈도 불러왔고 하니 이제 가중치를 저장해놓을 렌더 타겟을 추가로 선언해봅시다. - -``` -let weightRT; -``` -이름은 `weightRT`입니다. -렌더 타겟 생성은 기존의 `createRenderTargets()` 함수에 추가합니다. - -``` -function createRenderTargets() { - originalRT = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight); - originalRT.depthTexture = new THREE.DepthTexture(); - originalRT.depthTexture.format = THREE.DepthFormat; - originalRT.depthTexture.type = THREE.UnsignedShortType; - - weightRT = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight); -} -``` -단 한 줄이면 됩니다. -가중치 렌더 타겟은 깊이 텍스처가 필요 없거든요. - -### 5.3 가중치 추출 셰이더 -이제 5장에서 가장 어려운 파트입니다. -셰이더를 직접 짜보는 것이죠. - -만들 셰이더는 `originalRT`의 깊이 텍스처를 가지고 가중치를 계산하는 겁니다. -대체 뭔 가중치일까요? 바로 **라이트 셰프트의 강도 가중치**입니다. -이 가중치를 바탕으로 그 픽셀에 들어갈 라이트 셰프트의 강도가 정해집니다. -그럼 이거부터 알아야겠네요. 장면의 어느 부분이 가장 가중치가 높을까요? 정답은 **오브젝트가 없는 부분**입니다. -게임이나 현실에서 빛 줄기를 보면 어떻죠? 오브젝트가 없으면 완전 진하다가 오브젝트에 닿으면 점점 감쇄가 되죠? -그래서 빛 줄기가 오브젝트가 없는 곳으로는 쭉 뻗어나가는 거죠. 오브젝트가 있다면 점점 감쇄되다가 이내 사라집니다. -즉, 오브젝트가 없는 부분엔 특정 가중치를 주고, 없는 부분은 `0` 값이나 넣어버리면 되는 겁니다. - -어... 그러면 오브젝트가 있는지 없는지는 어떻게 알까요? `originalRT`를 보고 검은색은 오브젝트가 없다!로 치면 될까요? -아니죠, 그러면 검은색을 가진 오브젝트는 있음에도 불구하고 없는 것으로 처리되고 맙니다. -따라서 색상이 아닌 다른 무언가를 보고 오브젝트가 있는지 없는지를 판단해야해요. -답은 바로 **깊이**입니다. -깊이 텍스처는 색상에 관계없이 오브젝트가 없으면 `1`, 있으면 카메라에 얼마나 가깝냐에 따라 `0~1`까지의 값을 넣어줍니다. -이제 어떻게 해야할지 아시겠지요? -`originalRT`의 깊이 텍스처를 몰래 염탐해서 `1`이면 가중치를(흰색 또는 회색) 그리고, `1`이 아니면 `0`을(검은색)을 그려주면 됩니다. - -설명은 이만하고 코드부터 봅시다. - -``` -const WeightShader = { - uniforms: { - 'depthMap': { value: null } - }, - - vertexShader: ` - varying vec2 texCoord; - - void main() { - texCoord = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - }`, - - fragmentShader: ` - varying vec2 texCoord; - - uniform sampler2D depthMap; - - void main() { - float depth = texture2D(depthMap, texCoord).x; - float weight = (depth == 1.0) ? 0.3 : 0.0; - - gl_FragColor = vec4(weight, weight, weight, 1.0); - }` -}; -``` -문법은 엄청 복잡하지는 않습니다. 천천히 보도록 하죠. - -``` -uniforms: { - 'depthMap': { value: null } -}, -``` -먼저 `uniforms` 리스트는 **프로그램으로부터 받아올 오브젝트들의 모음집**입니다. -셰이더는 GPU에서 실행되므로 이렇게 `uniforms`라는 가방에 담아서 넘겨줘야 셰이더 내에서 사용할 수 있습니다. -지금같은 경우는 `originalRT`의 깊이 텍스처인 `depthMap`을 넘겨줍니다. `value`는 값을 설정하지 않았을 때의 기본 값입니다. - -``` -vertexShader: ` - varying vec2 texCoord; - - void main() { - texCoord = uv; - gl_Position = vec4(position, 1.0); - }`, -``` -다음은 **버텍스 셰이더**입니다. -아까 셰이더 패스가 알아서 화면에 꼭 맞는 평면을 준비해준다고 했죠? -그러므로 여기서 할 일은 없습니다. -포지션에 뷰/프로젝션 행렬을 곱해줄 필요가 없다는 뜻입니다. 그대로 `gl_Position`에 4차원 벡터화해서 대입해줍니다. -다만 프래그먼트 셰이더에서 `depthMap`을 추출해야하므로 `texCoord`라는 UV 값도 그대로 넘겨줍니다. -`texCoord`의 앞에 `varying` 키워드가 있어야 프래그먼트 셰이더로 값을 넘겨줄 수 있습니다. -프래그먼트 셰이더로 값이 넘어간다는 말은, 그 값은 반드시 **래스터라이제이션**이 된다는 의미입니다. -즉, 이제 픽셀(프래그먼트) 하나하나가 버텍스에 따라 보간된 **고유한 `texCoord` 값**을 갖게 됩니다. - -``` -fragmentShader: ` - varying vec2 texCoord; - - uniform sampler2D depthMap; - - void main() { - float depth = texture2D(depthMap, texCoord).x; - float weight = (depth == 1.0) ? 0.3 : 0.0; - - gl_FragColor = vec4(weight, weight, weight, 1.0); - }` -``` -**프래그먼트 셰이더**입니다. -보시면 텍스처의 경우 `sampler2D`라는 자료형입니다. -추가로 `uniforms`에서 왔다는 의미로 `uniform` 키워드를 붙여서 선언해야 합니다. -함수를 보면 간단합니다. `texture2D()` 함수를 이용해 깊이 텍스처로부터 값을 추출합니다. -추출한 결과의 자료형은 RGBA를 모두 갖고 있으므로 `vec4`가 됩니다. -하지만 우리가 필요한 것은 흰색이냐 아니냐죠? 아까 오브젝트가 없는 부분이 흰색이라고 했으니까요. -그렇다면 굳이 RGBA 값을 다 볼 필요가 없죠? 흰색이면 RGB 값이 모두 똑같으니 말이죠. -그러므로 `.x`를 붙여서 빨간색만 가져옵시다. `vec4`가 `float`으로 줄었으니 편하군요. -이제 추출한 `depth` 값이 `1`인지 검사합니다. 삼항 연산자를 사용하면 좋습니다. -저는 오브젝트가 없다면 `0.3`을 주었습니다. -예상 외로 조금 약하죠? 하지만 이보다 커지면 라이트 셰프트가 너무 강해지게 됩니다. -마지막으로 `gl_FragColor`에 `weight` 값으로 이뤄진 `vec4`를 던져주면 됩니다. -아마 검은색과 어두운 회색이 섞인 느낌의 텍스처가 탄생할 듯 합니다. - -조금 힘들었지만 어떻게든 셰이더를 짜냈습니다! -이제 이걸 사용해줄 패스와 이펙트 컴포저를 생성하러 가봅시다. - -### 5.4 이펙트 컴포저 및 패스 생성 -다시 쉬운 파트입니다. -먼저 선언부터 하겠습니다. - -``` -let weightComposer; -let weightPass; -``` -사용할 패스는 하나입니다. -하나일지라도 이펙트 컴포저는 꼭 필요하므로 넣어주세요. - -패스 생성 함수를 추가합시다. -여기서 이펙트 컴포저도 생성합니다. - -``` -function createPasses() { - weightComposer = new EffectComposer(renderer, weightRT); - weightComposer.renderToScreen = false; - - weightPass = new ShaderPass(WeightShader); - weightPass.uniforms['depthMap'].value = originalRT.depthTexture; - weightComposer.addPass(weightPass); -} -``` -먼저 **이펙트 컴포저**부터 만듭니다. -인자로 렌더러와 렌더 타겟을 받습니다. 만약 렌더 타겟을 넣지 않는다면 기본 렌더 타겟으로 설정됩니다. -정확히는 `renderToScreen`을 `false`로 해줘야만 `weightRT`에 렌더하게 됩니다. -이걸 `false`로 하지 않으면 **무조건 기본 렌더 타겟에 렌더**하므로 주의하세요! - -다음은 **패스**입니다. 셰이더 패스는 인자로 **셰이더 코드**를 받습니다. -우리가 정성스레 빚은 셰이더 코드를 전해줍시다. -이후 `uniforms`도 채워줘야 합니다. 아까 제가 셰이더에 들고갈 가방이라고 말했죠? -`depthMap`에 `originalRT`의 깊이 텍스처를 넣어주면 됩니다. -그리고 이펙트 컴포저에 패스를 추가하면 끝입니다. - -이 함수는 `createRenderTargets()` 함수 아래에서 호출하도록 해주시면 되겠습니다. - -### 5.5 자동 크기 조절 -실행하기 전에 손 볼 부분이 조금 있습니다. -4장 때처럼 `onWindowResize()` 함수를 수정해줘야합니다. - -``` -function onWindowResize() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - - renderer.setSize(window.innerWidth, window.innerHeight); - originalRT.setSize(window.innerWidth, window.innerHeight); - weightComposer.setSize(window.innerWidth, window.innerHeight); -} -``` -맨 아래에 `weightComposer`의 크기를 업데이트하도록 만들어주세요. -`weightRT`를 직접 조절하지 않아도 `weightComposer`의 `setSize()` 함수를 사용하면 알아서 크기가 바뀝니다. - -이제 실행하면 어떻게 될까요? -역시나 아무것도 보이지 않습니다. 어떻게 볼 수 있는 방법이 있을까요? -답은 아주 간단합니다. 이펙트 컴포저 생성 부분에서 `renderToScreen`을 `false`로 하는 줄을 **주석**처리하면 됩니다. - -그럼 이제 가중치 텍스처를 볼 수 있습니다! -결과물이 마찬가지로 잘 ~~안~~보인다면 제대로 하신겁니다. **짝짝짝짝짝!** - -</details> - -<mark>5장의 전체 스크립트는 [**여기**](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/blob/main/tutorial_data/scripts/script_5.js "Light shaft chapter 5 source code")에 있습니다.</mark> - -## 6장: 라이트 셰프트 - +## 주제 및 선정 이유 + +### 주제 +제가 선정한 주제는 **Light shaft**라는 효과입니다. +Light shaft란 광원에서 뿜어져나오는 빛이 오브젝트에 의해서 부분적으로 막히는 경우, 막히지 않는 부위에서 세어져 나오는 빛의 줄기입니다. +초기엔 **Three.js** 예제 중 **Godrays**를 참고하여 수정하려 했으나, 전체적으로 코드 파일이 많이 나눠져 있고 없어도 되는 구현이 많아 완전히 새로 만들었습니다. +베이스 코드와 셰이더 코드, 구현 원리 등 **Godrays** 예제에서 가져온 것은 아무것도 없으므로 결과만 비슷한 다른 프로그램이라고 생각해주시면 되겠습니다. +(구현 원리는 레퍼런스의 **HLSL Development Cookbook**를 참고했습니다.) + +### 선정 이유 +이전에 **Direct3D 11**을 독학한 적이 있어서 셰이더에 관한 기본 지식은 있었습니다. +하지만 이렇게 제대로 된 시연 프로그램을 만든 적은 없었기 때문에 이번에 시도해볼만한 조금 어려운 효과를 찾아보았습니다. +운 좋게도 예전에 사놨던 **HLSL** 책에 SSAO, SSLR, Bloom, Bokeh 등 여러 후처리 기법들이 있었습니다. +저는 그 중 **SSLR**(Screen-space light ray)를 선택했습니다. 이게 Light shaft의 다른 이름 중 하나입니다. +그리고 **Three.js** 예제를 찾아보니 **Godrays**라는 예제가 있었습니다. +코드를 쭉 봤습니다만 복잡하게 기술된 GLSL은 이해하기 너무 어려웠습니다. +그리하여 **GodRays** 예제를 수정하는 것은 포기하고 원리는 HLSL 책에서, 문법은 HLSL이 아닌 GLSL로 변경된 새로운 프로그램을 만들어보기로 했습니다. +어려운 주제를 정한 가장 핵심적인 이유는 볼 만한 가치가 높기 때문입니다. +구글에 검색했을 때 어떻게 쓰는지 간단하게 바로 나오는 기능은 별로 하고싶은 마음이 들지 않았습니다. + +## 설명 요약 + +### 작동 단계 +렌더링 단계를 보면 이해하기 쉽습니다. +1. 기본 장면을 `originalRT` 렌더 타겟에 렌더 +1. `originalRT`의 깊이 텍스처를 바탕으로 해당 픽셀에서의 오브젝트의 존재 유/무 가중치를 추출한 뒤 `weightRT` 렌더타겟에 렌더 +1. `originalRT`와 `weightRT`를 바탕으로 직접 작성한 `Light shaft` 셰이더를 이용하여 기본(윈도) 렌더 타겟에 렌더 + +### 작동 원리 +기본적으로 **스크린 공간**(Screen-space)을 이용한 기법인 만큼 **후처리**(Post-processing) 방식을 주로 이용합니다. +`Light shaft` 셰이더 또한 2번의 후처리를 해야만 구현할 수 있습니다. +즉, 2번의 셰이더를 거쳐야 합니다. +첫 번째로는 가중치 추출 셰이더인데, 이 셰이더는 해당 픽셀에 오브젝트가 있는지 없는지를 깊이 텍스처를 통해 판별합니다. +두 번째는 라이트 셰프트 셰이더로, 픽셀과 태양 사이에 ray를 쏴서 그 경로에 있는 가중치를 일정 수만큼 추출하여 감쇄 적용 후 누적시켜 해당 픽셀의 빛 줄기 강도 값을 구합니다. +요약이라 많이 어려워 보일 수 있으나 튜토리얼을 보면 차근차근 이해할 수 있습니다. +마지막으로 기본 장면을 그린 렌더 타겟과 라이트 셰프트 값을 합쳐주면 구현 완료입니다. + +## 개발 과정 <details> - -<summary><b>6장 튜토리얼 (펼치기/접기)</b></summary> - -이제 **라이트 셰프트**를 완성할 차례입니다. - -### 6.1 상수와 컨트롤 변수 -그 전에 **상수**부터 조금 추가해주겠습니다. - -``` -const constants = { - ~ - defaultRayLength: 1, - defaultRayColor: new Color(0x444444), - rayLengthMultiplier: 24, - ~ -}; -``` -이렇게 세 개를 넣어주세요. -빛 줄기의 길이와 색, 길이 배수입니다. -길이 배수는 나중에 설명하도록 하겠습니다. - -다음은 **컨트롤 변수**를 추가합시다. - -``` -const controls = { - ~ - rayLength: constants.defaultRayLength, - rayColor: constants.defaultRayColor, - ~ -}; -``` -`constants`에 있는 값 그대로 넣어주시면 됩니다. - -### 6.2 라이트 셰프트 셰이더 -제일 중요한 **라이트 셰프트 셰이더** 작성입니다. -일단 이걸 이해하려면 어떻게 빛 줄기를 만드는지 알아야 합니다. -먼저 그림부터 보도록 합시다. - - - -보면 태양으로부터 멀어질수록, 특히 오브젝트에 가려질수록 빛 줄기가 약해집니다. -빛 줄기의 강도는 당연히 프래그먼트 셰이더에서 계산해야겠지요? -그런데 프래그먼트 셰이더의 호출 단위는 프래그먼트, 즉 화면의 **픽셀당 한 번씩 실행**된다고 보시면 됩니다. -그럼 맨 아래쪽에 빛 줄기가 거의 닿지 않는 나무 부분을 봅시다. -이 녀석은 어떻게 자기가 자신보다 위에 있는 나뭇가지에 가려져있다는 사실을 알까요? -답은 바로 **현재 픽셀로부터 태양까지의 거리 벡터**를 이용하는 겁니다. -그 다음 구한 거리 벡터를 일정한 양으로 나눠서 그 나눠진 수만큼 각각 가중치 맵에서 가중치를 꺼냅니다. -그렇게 꺼낸 각각의 가중치에 점점 커지는 감쇄를 적용한 후, 그 값들을 모두 더해버리면 그게 그 픽셀의 빛 줄기의 양이 됩니다. -예...말로 설명하면 확실히 이해하기 난해하죠. -그래서 그림을 준비해봤습니다. - - - -자, 여러분이 추출한 **가중치 텍스처**입니다. -`stepCount`는 방금 말했던 픽셀로부터 태양까지의 나눠진 수를 의미합니다. -그리고 그 스텝마다 가중치를 뽑아서 조금씩 늘어나는 감쇄량을 적용한 뒤 누적합니다. -슬슬 이해가 되시나요? - -그럼 이제 셰이더를 작성해봅시다! - -``` -const LightShaftShader = { - uniforms: { - 'originalRTMap': { value: null }, - 'weightRTMap': { value: null }, - 'lightPosition': { value: new Vector3() }, - 'rayColor': { value: new Color() }, - 'stepCount': { value: 0 } - }, - - vertexShader: ` - varying vec2 texCoord; - - void main() { - texCoord = uv; - gl_Position = vec4(position, 1.0); - }`, - - fragmentShader: ` - varying vec2 texCoord; - - uniform sampler2D originalRTMap; - uniform sampler2D weightRTMap; - uniform vec3 lightPosition; - uniform vec3 rayColor; - uniform int stepCount; - - void main() { - float initDecay = 0.2; - float distDecay = 0.8; - - vec4 albedo = texture2D(originalRTMap, texCoord); - - vec2 dirToLight = lightPosition.xy - texCoord; - float lengthToLight = length(dirToLight); - dirToLight /= lengthToLight; - - float deltaLength = min(0.005, lengthToLight * 1.0 / float(stepCount - 1)); - - vec2 samplePosition = vec2(0.0, 0.0); - vec2 rayOffset = vec2(0.0, 0.0); - vec2 rayDelta = dirToLight * deltaLength; - float rayIntensity = 0.0; - float stepDecay = distDecay * deltaLength; - float currentDecay = initDecay; - float currentIntensity = 0.0; - - for (int i = 0; i < stepCount; ++i) - { - samplePosition = texCoord + rayOffset; - currentIntensity = texture2D(weightRTMap, samplePosition).x; - - rayOffset += rayDelta; - rayIntensity += currentIntensity * currentDecay; - currentDecay = clamp(currentDecay - stepDecay, 0.0, 1.0); - } - - albedo.rgb = clamp(albedo.rgb + (rayColor * rayIntensity), 0.0, 1.0); - - gl_FragColor = albedo; - }` -}; -``` -꽤 길죠? -하나하나 살펴봅시다. - -``` -uniforms: { - 'originalRTMap': { value: null }, - 'weightRTMap': { value: null }, - 'lightPosition': { value: new Vector3() }, - 'rayColor': { value: new Color() }, - 'stepCount': { value: 0 } -}, -``` -`uniforms`의 경우 필요한 변수가 많아졌습니다. -`origianlRTMap`은 계산한 빛 줄기와 **실제 렌더된 장면**을 합치기 위해 필요합니다. -`weightRTMap`은 빛 줄기의 강도를 계산할 때 사용할 **가중치** 텍스처입니다. -`lightPosition`은 **태양의 UV 공간 위치**입니다. 태양과 픽셀 사이의 벡터를 따라 가중치 텍스처를 추출하려면 꼭 필요합니다. -`rayColor`는 **빛 줄기의 색상**이고요. -`stepCount`는 위에서 말한 벡터의 **분할 횟수**입니다. - -``` -vertexShader: ` - varying vec2 texCoord; - - void main() { - texCoord = uv; - gl_Position = vec4(position, 1.0); - }`, -``` -휴, 그나마 버텍스 셰이더는 이전이랑 똑같습니다. - -``` -fragmentShader: ` - varying vec2 texCoord; - - uniform sampler2D originalRTMap; - uniform sampler2D weightRTMap; - uniform vec3 lightPosition; - uniform vec3 rayColor; - uniform int stepCount; - - void main() { - float initDecay = 0.2; // 기본 감쇄량 - float distDecay = 0.8; // 최종적으로 줄어들 감쇄량 - - vec4 albedo = texture2D(originalRTMap, texCoord); // 원래 장면의 컬러 값 - - vec2 dirToLight = lightPosition.xy - texCoord; // 픽셀로부터 태양까지의 벡터 - float lengthToLight = length(dirToLight); // 그 벡터의 길이 - dirToLight /= lengthToLight; // 길이 구하는 김에 정규화까지 - - float deltaLength = min(0.005, lengthToLight * 1.0 / float(stepCount - 1)); // 벡터를 분할한 길이 - - vec2 rayOffset = vec2(0.0, 0.0); // 가중치 텍스처를 추출할 위치 오프셋 값 - vec2 rayDelta = dirToLight * deltaLength; // 벡터의 다음 지점으로 가기 위한 거리 - float rayIntensity = 0.0; // 최종 빛 줄기의 강도 - float stepDecay = distDecay * deltaLength; // 감쇄의 매 증가량 - float currentDecay = initDecay; // 현재 감쇄를 기본 감쇄량으로 설정 - float currentIntensity = 0.0; // 현재 추출의 빛 줄기 강도 - - for (int i = 0; i < stepCount; ++i) // stepCount만큼 반복 - { - currentIntensity = texture2D(weightRTMap, texCoord + rayOffset).x; // 가중치 텍스처에서 rayOffset만큼 이동한 위치의 가중치를 추출 - - rayOffset += rayDelta; // 다음 지점으로 이동 - rayIntensity += currentIntensity * currentDecay; // 감쇄량 적용 후 누적 - currentDecay = clamp(currentDecay - stepDecay, 0.0, 1.0); // 감쇄량 증가 - } - - albedo.rgb = clamp(albedo.rgb + (rayColor * rayIntensity), 0.0, 1.0); // 최종 빛 줄기의 강도에 색상을 곱하고 원래 장면과 병합 - - gl_FragColor = albedo; // 병합한 값을 최종 출력 - }` -``` -프래그먼트 셰이더가 어려울 수 있어서 주석을 넣었습니다. -어려운 부분만 다시 보도록 합시다. - -``` -vec2 dirToLight = lightPosition.xy - texCoord; // 픽셀로부터 태양까지의 벡터 -float lengthToLight = length(dirToLight); // 그 벡터의 길이 -dirToLight /= lengthToLight; // 길이 구하는 김에 정규화까지 -``` -먼저 픽셀로부터 태양까지의 벡터를 구하는데요. -미리 `lightPosition`을 **UV 공간으로 변환**시켜서 넣어줬기 때문에 같은 공간에 있는 `texCoord`와 빼기 연산을 통해 거리 벡터를 구할 수가 있습니다. -사실 아직 `lightPosition`을 변환하는 코드는 작성하지 않았죠? 나중에 작성하면 됩니다. -`lengthToLight` 값을 `length()` 함수를 통해 구합니다. 이 함수는 GLSL에서 기본으로 제공하는 함수입니다. -`dirToLight /= lengthToLight`는 `dirToLight` 벡터를 **정규화(normalization)**합니다. - -``` -float deltaLength = min(0.005, lengthToLight * 1.0 / float(stepCount - 1)); // 벡터를 분할한 길이 -``` -다음은 `deltaLength`인데요. -일단 `float(stepCount - 1)`에서 `1`을 빼주는 이유가 뭘까요? -왜냐하면 추출은 **픽셀의 위치부터 시작**하기 때문입니다. -예를 들어 `stepCount`가 16일 때, 추출은 16번 반복하겠죠? -그럼 원점을 추출하면 반복 수가 `15`번이 남게 됩니다. -여기서 `1`을 빼지 않은 16으로 벡터를 나누게 되면, 반복당 `1/16`만큼 앞으로 나아가게 됩니다. -그럼 남은 15번의 반복을 끝냈을 때 최종 위치가 `16/16`이 아닌 `15/16`이 되어버리고 말아요. -따라서 태양의 위치까지 닿으려면 `1`을 빼주는 것이 맞습니다. - -그 다음으로는 `min()` 함수가 보이네요. -왜 굳이 `0.005`보다 크면 `0.005`로 고정시켜주는 걸까요? -왜냐하면 추출 간격이 너무 넓어지면(`stepCount` 값이 너무 작아지면) **빛 줄기의 강도가 퍼지기 때문**입니다. -그래서 최소한의 간격으로 `0.005`를 지정해주었습니다. -이렇게 하면 태양까지 닿지 않을 수도 있지만, 빛 줄기가 퍼져서 이상한 모습을 보는 일은 없습니다. -추가로 `float(stepCount - 1)` 없이 그냥 `0.005`로 고정시켜버리면 어떻게 될까요? -태양과 너무 가까운 거리에 있는 픽셀도 `0.005`라는 큰 숫자를 받기 때문에 태양 위치에서 **빛이 모이는 듯한 현상**이 일어납니다. -따라서 `min()` 함수를 사용한 이유는 태양과의 거리에 따라 **적당히 좋은** 간격을 지정해주기 위함이라고 생각하시면 편합니다. - -``` -for (int i = 0; i < stepCount; ++i) // stepCount만큼 반복 - { - currentIntensity = texture2D(weightRTMap, texCoord + rayOffset).x; // 가중치 텍스처에서 rayOffset만큼 이동한 위치의 가중치를 추출 - - rayOffset += rayDelta; // 다음 지점으로 이동 - rayIntensity += currentIntensity * currentDecay; // 감쇄량 적용 후 누적 - currentDecay = clamp(currentDecay - stepDecay, 0.0, 1.0); // 감쇄량 증가 - } -``` -빛 줄기 강도를 계산하는 반복문입니다. -주석만 봐도 대충 어떤 식으로 돌아가는지 알 수 있을겁니다. -`texCoord` 값을 수정하면 현재 픽셀이 아닌 다른 픽셀 위치에도 접근할 수 있다는 사실을 기억하세요. -마찬가지로 가중치 값은 흑백이기 때문에 RGB 값이 모두 같아서 `x`값만 가져옵니다. -이후엔 다음 지점으로 가고, 누적하고, 감쇄량을 업데이트합니다. - -여기서 잠깐, 감쇄량의 기본 값을 보면 `0.2`죠? -즉, 계산된 강도의 `20%`만 누적하겠다는 뜻입니다. -분명 가중치도 `0.3`으로 지정했으니 곱하면 `0.06`씩 누적되겠군요. 상당히 작습니다. -근데 최종 감쇄 증가량을 보면 `0.8`입니다. `0.2`보다 큰 이유는 태양과의 거리에 따라 감쇄 추가량이 달라지기 때문입니다. -쓰고 보니 조금 복잡한데, 일단은 기본 감쇄량이 `0.2`이고 최종 감쇄량이 `0.8`일 때 제일 좋은 결과가 나와서 저는 그렇게 고정했습니다. - -``` -albedo.rgb = clamp(albedo.rgb + (rayColor * rayIntensity), 0.0, 1.0); // 최종 빛 줄기의 강도에 색상을 곱하고 원래 장면과 병합 - -gl_FragColor = albedo; // 병합한 값을 최종 출력 -``` -마지막으로 빛 줄기 강도와 색상을 곱합니다. -그리고 원래 장면에 더하는데요, 곱하지 않고 더하는 이유가 뭘까요? -빛 줄기는 원래 장면에 추가되어 씌워지는 값이기 때문입니다. -이 경우 값이 `1.0`을 넘어갈 수 있기 때문에 `clamp()` 함수로 `0~1` 범위로 맞춰줍니다. -이후 최종 결과 값을 출력하면 셰이더 완성! - -### 6.3 패스 생성 -이제 **패스**를 만들어볼까요. -먼저 선언부터 해야지요. - -``` -let lightShaftComposer; -let lightShaftPass; -``` -이렇게 두 개를 추가해줍니다. -어, `weightComposer`에 `lightShaftPass`를 추가해주면 안 될까요? -안 됩니다. 컴포저는 **하나의 렌더 타겟**만 받는 것 같더라고요. -가중치 추출할 때 보셨겠지만 렌더 타겟이 `weightRT`였죠? -`lightShaftPass`는 기본 렌더 타겟에 그릴 것이기 때문에 따로 컴포저를 만들어줘야 합니다. - -이제 `createPasses()` 함수로 갑니다. -``` -function createPasses() { - ~ - lightShaftComposer = new EffectComposer(renderer); - - lightShaftPass = new ShaderPass(LightShaftShader); - lightShaftPass.uniforms['originalRTMap'].value = originalRT.texture; - lightShaftPass.uniforms['weightRTMap'].value = weightRT.texture; - lightShaftPass.uniforms['lightPosition'].value = new Vector3(); - lightShaftPass.uniforms['rayColor'].value = constants.defaultRayColor; - lightShaftPass.uniforms['stepCount'].value = Math.round(constants.defaultRayLength * constants.rayLengthMultiplier); - lightShaftComposer.addPass(lightShaftPass); -} -``` -`weightPass` 만드는 곳 아래에 이렇게 추가해주세요. -`uniforms`의 요소를 넣어주는 걸 보면 `lightPosition`의 경우 그냥 영벡터를 넣어줍니다. -이유는 어차피 **매 프레임마다 바꿔줘야하기 때문**입니다. -태양의 UV 공간 위치는 카메라가 움직일 때, 태양 자체를 움직일 때 등 여러 변수에 의해 바뀔 수 있으니까요. -`stepCount`는 상수에서 정의한 기본 빛 줄기 길이 배수를 곱한 다음 반올림해서 넘겨줍니다. 셰이더에서 사용하는 `stepCount`는 `int`형이니까요. -다 끝나면 역시 이펙트 컴포저에 추가해줍니다. - -### 6.4 태양 위치 좌표계 변환 -이제 태양의 위치를 UV 공간으로 옮겨봅시다. -그럼 과정은 아래처럼 되겠군요. - -**월드 공간** -> **뷰 공간** -> **프로젝션 공간** -> **UV 공간** - -3번이나 변환해줘야 하는 것 같지만 우리에겐 좋은 함수가 하나 있습니다. -바로 `project()` 함수입니다. 인자로 카메라만 넣어주면 카메라의 뷰/프로젝션 행렬을 알아서 곱해줍니다. -따라서 이것만 있으면 프로젝션 공간으로는 코드 한 줄로 갈 수 있다는 뜻이죠. -코드를 봅시다. `update()` 함수로 가세요. - -``` -function update() { - stats.update(); - - const uvSunPosition = new Vector3(); - uvSunPosition.copy(directionalLight.position); - - uvSunPosition.project(camera); - uvSunPosition.x = (uvSunPosition.x + 1) * 0.5; - uvSunPosition.y = (uvSunPosition.y + 1) * 0.5; - - lightShaftPass.uniforms['lightPosition'].value = uvSunPosition; -} -``` -이렇게 추가해주면 됩니다. - -``` -uvSunPosition.project(camera); -uvSunPosition.x = (uvSunPosition.x + 1) * 0.5; -uvSunPosition.y = (uvSunPosition.y + 1) * 0.5; -``` -디렉셔널 라이트의 위치를 복사한 `uvSunPosition` 벡터의 `project()` 함수를 실행해줍니다. -그러면 이제 `uvSunPosition` 벡터는 프로젝션 공간인 `-1~1` 범위에 위치하게 됩니다. -하지만 **UV 공간**으로 보내는 과제가 남았습니다. 어떻게 하면 될까요? -머릿속으로도 계산 가능할만큼 간단합니다. -각 요소에 `1` 더하고 반으로 나누면 땡입니다. 그럼 알아서 `0~1` 범위로 맞춰지겠죠? - -이후 `uniforms`에 직접 수정해주면 완성입니다. - -### 6.5 렌더 순서 -그럼 `render()` 함수도 손 좀 봐야겠죠? - -``` -function render() { - renderer.setRenderTarget(originalRT); - renderer.render(scene, camera); - renderer.setRenderTarget(null); - - weightComposer.render(scene, camera); - lightShaftComposer.render(scene, camera); -} -``` -맨 밑줄에 `lightShaftComposer.render(scene, camera);`를 추가해주면 됩니다. -순서가 굉장히 중요하다는 점 꼭 기억해두시기 바랍니다. -`weightComposer`는 `originalRT` 깊이 텍스처가 필요하고 -`lightShaftComposer`는 `originalRT`와 `weightRT`가 모두 필요합니다! - -### 6.6 GUI 추가 -거의 다 됐습니다. 이제 GUI를 통해 빛 줄기의 색상 및 길이를 수정할 수 있도록 합시다. - -``` -function createGUI() { - ~ - lightFolder.add(controls, 'rayLength', 0, 2).listen().onChange(function (value) { - lightShaftPass.uniforms['stepCount'].value = Math.round(value * constants.rayLengthMultiplier); - }); - lightFolder.addColor(controls, 'rayColor').listen().onChange(function (value) { - lightShaftPass.uniforms['rayColor'].value = value; - }); - ~ -} -``` -이렇게 `createGUI()` 함수에 추가해주세요. -의미는 설명 안 해도 알겠죠? - -### 6.7 자동 크기 조절 -이펙트 컴포저가 늘었으니 이것도 해줘야 합니다. - -``` -function onWindowResize() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - - renderer.setSize(window.innerWidth, window.innerHeight); - originalRT.setSize(window.innerWidth, window.innerHeight); - weightComposer.setSize(window.innerWidth, window.innerHeight); - lightShaftComposer.setSize(window.innerWidth, window.innHeight); -} -``` -맨 아랫줄에 추가해주세요. - -휴... 드디어 다 했습니다. 실행하셔도 됩니다. -빛 줄기가 잘 보이시나요? -결과물이 잘 보인다면 제대로 하신겁니다. **짝짝짝짝짝짝!** +<summary>개발 과정 (펼치기/접기)</summary> + +### 1일차 +**1일차**에는 주제를 정한 뒤 간단히 기본 장면을 만들었습니다. +모두 수업에 나온 내용인지라 전혀 어렵지 않았습니다. +그런데 잠깐, 애초에 Three.js에서 HLSL 책에 나온 원리로 구현이 가능한지가 궁금했습니다. +저도 모르는 어떠한 제한사항으로 인해 만들지 못하는 구조일 경우 일찍 포기해야만 했기 때문입니다. + +사실 라이트 셰프트 셰이더는 Direct3D에서는 컴퓨트 셰이더로 계산하는 방식입니다. +컴퓨트 셰이더란 GPU의 스레드 그룹을 직접 배치하고 사용하는 방식으로 후처리 또는 파티클 기법에 최적화된 방식이며, 계산 속도가 굉장히 빠릅니다. +하지만 Three.js로 컴퓨트 셰이더를 사용할 수 있을지 없을지 몰랐기 때문에 그냥 없는 것으로 가정했습니다. +그럼 이제 컴퓨트 셰이더 코드를 버텍스-프래그먼트 셰이더 코드로 변환할 수 있는지를 알아야 했습니다. +그래서 저는 아주 오래된 프로그램인 렌더몽키를 꺼냈습니다(그리고 셰이더 프로그래밍 입문 책도). +렌더몽키는 AMD에서 만든 DirectX 9 셰이더 작성 프로그램입니다. +DirectX의 버텍스-픽셀 셰이더가 WebGL의 버텍스-프래그먼트 셰이더에 대응되므로 여기서 잘 작동하면 별 탈 없이 작동될 터였습니다. +그렇게 렌더몽키에 잘 구현해봤더니 정상적으로 작동했습니다. 의외로 사양도 높지 않았습니다. + + + +### 2일차 +**2일차**에는 렌더 타겟 사용법을 익혔습니다. +Direct3D면 몰라도 Three.js는 처음 사용하는 라이브러리이고 수업에서도 제대로 배우지 않았으니 많은 구글링을 요구했습니다. +렌더 타겟 다루는 법을 안 뒤에는 기본 장면을 `originalRT`에 렌더했습니다. +그 다음에 할 일은 렌더 타겟에 깊이 텍스처를 넣는 것이었습니다. +[Three.js webgl_depth_texture](https://github.com/mrdoob/three.js/blob/master/examples/webgl_depth_texture.html) 예제가 많은 도움이 됐습니다. +추가 코드는 예상 외로 쉬웠습니다만, 실제로 잘 찍혔는지 봐야 하는데 전부 하얘서 손을 좀 봐야 했습니다. +깊이 텍스처까지 완성한 후에 GLSL 셰이더 사용법을 공부했습니다. +[Three.js webgl_postprocessing](https://github.com/mrdoob/three.js/blob/master/examples/webgl_postprocessing.html) 예제를 참고해 후처리에 사용되는 셰이더는 뭔가 다르다는 것을 알아챘습니다. +후처리 셰이더의 경우 `ShaderPass`와 `EffectComposer`를 무조건 사용해야만 했기에 그것들도 모두 공부해야 했습니다. +그래도 사용법이 쉬워서 참 다행이었습니다. +`ShaderPass`의 경우, Screen-aligned quad 도형을 자동으로 넣어주는 클래스였습니다. +`EffectComposer`는 그러한 `ShaderPass`를 렌더링해주는 대기열 클래스였습니다. +이후 GLSL을 사용해 가중치 추출 셰이더를 만들고 화면에 렌더하여 확인하는 데 성공했습니다. + +### 3일차 +**3일차**에는 라이트 셰프트 셰이더를 만들었습니다. +렌더몽키에서 사용했던 알고리즘 그대로 GLSL화 하여 만들었는데, 문제가 하나 있었습니다. +바로 태양의 위치를 월드 좌표계에서 UV 좌표계로 변환하는 일이었습니다. +저는 카메라로부터 뷰 행렬과 프로젝션 행렬을 가져오는 방법을 몰랐기에 많이 방황했습니다. +이후 3차원 벡터에 `project()` 함수가 있다는 것을 깨달았으나 그마저도 오류가 많이 나 시간을 많이 잡아먹었습니다. +많은 노력 끝에 완전히 성공한 셰이더의 모습을 확인할 수 있었습니다. +하지만 아직 GUI 추가 및 카메라 움직임 등 할 것들이 많이 남아있었습니다. + +### 4일차 +**4일차**에는 뒷정리 및 부가 기능을 개발했습니다. +튜토리얼의 7장에 해당하는 내용입니다. +프리셋 기능은 시간이 많이 걸렸지만 은근 쉬웠습니다. +태양의 자동 움직임 기능은 `Clock`의 `getDelta()` 함수를 사용했는데, 이 함수의 결과가 제가 알던 방식의 값과 조금 달라서 놀랐습니다. +유니티 같은 게임 엔진을 보면 이전 프레임과 현재 프레임 사이의 값을 반환하는 것이 원칙인데 여기의 함수는 직전의 `getDelta()` 함수와의 시간차이를 반환했습니다. +삼각함수와 함께 부드러운 움직임을 구현한 후 태양 오브젝트 변경 기능을 만들었습니다. +처음엔 리사 수의 사진을 넣어볼까 했는데 프로젝트가 MIT 라이선스인지라 관뒀습니다~(리사 수는 MIT에서 박사 학위까지 땄지만 말입니다 하하)~. +이후 제가 직접 짱 귀여운 고양이를 그려서 넣었습니다. +그렇게 부가 기능도 끝나고 이제 튜토리얼만 남은 상황이었습니다. + +### 5~7일차 +**5일차부터 7일차**에는 `readme.md` 파일에 마크다운으로 튜토리얼을 작성했습니다(이때는 `index.html`에 작성해야 한다는 사실을 몰랐습니다). +코드 추출하고 설명 쓰고 많이 힘들었지만 다 하니 참 많이도 썼구나 생각했습니다. + +### 8~10일차 +**8일차**, `index.html`에 모든 것을 집어넣어야 한다는 것을 깨달은 저는 다시 작업에 들어갔습니다. +중요한 점은 제가 HTML을 안 한지 대략 7년이 지나 아무것도 모른다는 것이었습니다. +결국 다시 공부해야만 했습니다. +아주 많은 시간 끝에 마크다운을 번쩍 들어 마크업으로 바꾸는 데 성공했습니다. + +### 11일차 +**11일차**에는 마지막으로 `index.html`에 프로그램 시연까지 봉합하여 모든 작업을 마쳤습니다. +그리고 보고서까지 작성 완료한 후 제출했습니다. + +**정말 놀라운 점은 아직도 교수님께서 OK를 해주지 않으셨습니다.** </details> -<mark>6장의 전체 스크립트는 [**여기**](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/blob/main/tutorial_data/scripts/script_6.js "Light shaft chapter 6 source code")에 있습니다.</mark> - -## 7장: 꾸미기 - - -<details> - -<summary><b>7장 튜토리얼 (펼치기/접기)</b></summary> - -자, 7장부터는 옵션입니다. -그러니까 하셔도 되고 안 하셔도 됩니다. -추가할 기능은 다음과 같습니다. - -* 태양 또는 카메라를 양 옆으로 왔다갔다하도록 만들기 -* 프리셋 기능 만들기 -* 태양 오브젝트 변경 기능 만들기 - -### 7.1 상수와 컨트롤 변수 -**상수**부터 추가합니다. - -``` -const constants = { - ~ - autoSunMoveSpeed: 0.5, - autoCameraMoveSpeed: 0.3, - ~ -} -``` -이렇게 두 개 넣어주시면 됩니다. -각각 태양과 카메라의 자동 이동 속도입니다. - -다음은 **컨트롤 변수**입니다. - -``` -const controls = { - ~ - presets: 'default', - sunObject: 'Sphere', - autoSunMove: false, - autoCameraMove: false, - ~ -} -``` -이렇게 추가해주세요. -`presets`와 `sunObject`는 목록용이고, `autoSunMove`와 `autoCameraMove`는 체크박스용입니다. - -### 7.2 움직임 GUI 추가 -**체크박스**를 만들어봅시다. -바꿔줄 게 딱히 없어서 코드가 매우 짧아요. - -``` -function createGUI() { - ~ - generalFolder.add(controls, 'autoSunMove').listen(); - generalFolder.add(controls, 'autoCameraMove').listen(); - ~ -} -``` -이렇게 두 줄만 넣어주시면 됩니다. -`onChange()` 함수도 필요 없어요. - -### 7.3 움직임 로직 구현 -자, 이제 `update()` 함수에 로직을 넣어봅시다. -왔다갔다 하는 데 가장 좋은 함수는 역시 `sin()` 함수겠죠? -그럼 안에 넣을 누적 각도 값이 필요합니다. -누적 없이 그냥 넣으면 컴퓨터 성능에 따라 이동 속도가 천차만별이 되어버려요. -그럼 컴퓨터 사양에 관계없이 일정한 것은 무엇일까요? 바로 시간입니다. - -``` -const clock = new THREE.Clock(); -``` -`run()` 함수 위에 이렇게 추가해주세요. -이게 있으면 이제 시간을 잴 수 있습니다. - -``` -let sunTime = 0; -let cameraTime = 0; -``` -누적 시간 변수도 추가해주시고요. - -이제 `update()` 함수로 갑니다. - -``` -function update() { - ~ - const deltaTime = clock.getDelta(); - - if (controls.autoSunMove) { - sunObject.position.x = Math.sin(sunTime) * 500; - directionalLight.position.x = sunObject.position.x; - - sunTime += deltaTime * constants.autoSunMoveSpeed; - sunTime = (sunTime >= Math.PI * 2) ? sunTime - Math.PI * 2 : sunTime; - } - if (controls.autoCameraMove) { - camera.position.x = Math.sin(cameraTime) * 300; - - cameraTime += deltaTime * constants.autoCameraMoveSpeed; - cameraTime = (cameraTime >= Math.PI * 2) ? cameraTime - Math.PI * 2 : cameraTime; - } - ~ -} -``` -이렇게 추가해주세요. 하나씩 봅시다. - -``` -const deltaTime = clock.getDelta(); -``` -먼저 보이는 건 이거군요. `getDelta()` 함수가 뭘까요? -`getDelta()` 함수는 마지막으로 `getDelta()` 함수가 실행됐을 때부터 방금 호출한 때까지의 **걸린 시간**을 반환합니다. -즉, 지금 이 친구가 반환하는 값은 이전 프레임으로부터 지금 프레임까지의 걸린 시간이 되겠군요! -이걸 왜 쓸까요? 이게 있어야 **컴퓨터 사양에 관계없이 동일한 속도**로 오브젝트를 움직일 수 있기 때문입니다. -예를 들어 1초 동안 `getDelta()` 함수의 값을 누적했다고 합시다. -A의 컴퓨터는 그 1초 동안 60프레임이, B의 컴퓨터는 30프레임이 나왔습니다. -하지만 누적 값은 같습니다. A는 1/60 값이 60번 더해졌을 것이고, B는 1/30 값이 30번 더해졌을 테니까요. -지금은 이해가 안 간다고 해도 직접 쓰는 걸 보면 이해가 갈 겁니다. - -``` -if (controls.autoSunMove) { - sunObject.position.x = Math.sin(sunTime) * 500; - directionalLight.position.x = sunObject.position.x; - controls.sunPositionX = sunObject.position.x; - - sunTime += deltaTime * constants.autoSunMoveSpeed; - sunTime = (sunTime >= Math.PI * 2) ? sunTime - Math.PI * 2 : sunTime; - } -``` -`autoSunMove`가 활성화되었을 때만 작동합니다. -그리고 `x` 위치 값을 `Math.sin(sunTime)`에 `500`을 곱해서 설정합니다. -이러면 이제 `x` 위치 값은 `-500~500` 범위를 왔다갔다 하겠죠? -이후엔 `sunTime`에 `deltaTime`을 더합니다. 추가로 속도 값을 곱해서요. -이러면 `sunTime` 값은 실제 시간 1초가 지났을 때 무조건 `1`이 됩니다. 10초가 지나면 `10`이 되고요. -하지만 라디안은 `PI * 2`부터 다시 원점으로 돌아오죠? -그래서 삼항 연산자를 사용해 `0~(PI * 2)` 범위에 있도록 만들어줬습니다. - -``` -if (controls.autoCameraMove) { - camera.position.x = Math.sin(cameraTime) * 300; - - cameraTime += deltaTime * constants.autoCameraMoveSpeed; - cameraTime = (cameraTime >= Math.PI * 2) ? cameraTime - Math.PI * 2 : cameraTime; -} -``` -카메라의 움직임도 똑같습니다. -범위랑 속도만 조금 다를 뿐입니다. - -이제 실행해보세요. -체크박스를 조작하면 태양과 카메라가 잘 움직이나요? - -### 7.4 프리셋 GUI 추가 -이제 프리셋 기능을 만들어봅시다. -먼저 변수 하나를 넣어줘야해요. - -``` -let fantasyTime = 0; -``` -`fantasy` 프리셋을 위한 애니메이션 변수입니다. - -이제 `createGUI()` 함수로 갑시다. - -``` -function createGUI() { - ~ - function changePreset( - backgroundColor, - ambientLightColor, - sunLightColor, - sunLightIntensity, - sunColor, - sunPositionY, - rayLength, - rayColor) { - controls.backgroundColor = backgroundColor; - controls.ambientLightColor = ambientLightColor; - controls.sunLightColor = sunLightColor; - controls.sunLightIntensity = sunLightIntensity; - controls.sunColor = sunColor; - controls.sunPositionY = sunPositionY; - controls.rayLength = rayLength; - controls.rayColor = rayColor; +## 완성 후 느낀 점 +다사다난했던 개발 과정으로 한 30시간 이상을 투자한 듯 하지만, 오랜만에 개발이 정말 재밌었습니다. +거의 열흘 동안 놀지도 않고 만들었는데, 하루종일 게임을 하는 기분이었습니다(너무 바쁘게 몰입해서 그런 듯 합니다). +물론 튜토리얼 작성할 때랑 HTML로 변환할 때는 조금 지루했습니다. +추가로 초기 튜토리얼에 재밌는 장난이나 사진도 많았는데, MIT 라이선스라 많이 지웠습니다. 조금은 아쉽네요. - renderer.setClearColor(controls.backgroundColor); - ambientLight.color = controls.ambientLightColor; - directionalLight.color = controls.sunLightColor; - directionalLight.intensity = controls.sunLightIntensity; - sunObject.material.color = controls.sunColor; - directionalLight.position.y = sunObject.position.y = controls.sunPositionY; - lightShaftPass.uniforms['stepCount'].value = Math.round(controls.rayLength * constants.rayLengthMultiplier); - lightShaftPass.uniforms['rayColor'].value = rayColor; - } - ~ -} -``` -조금은 무식해보이는 `changePreset()` 함수를 `createGUI()` 함수 안에 추가해줍니다. -프리셋에서 변경하는 값의 리스트를 인자로 보내면 실제로 그렇게 적용시켜주는 함수입니다. - -``` -function createGUI() { - generalFolder.add(controls, 'presets', ['default', 'afternoon', 'sunset', 'night', 'eclipse', 'fantasy']).listen().onChange(function (value) { - switch (value) { - case 'default': - changePreset( - constants.defaultBackgroundColor, - constants.defaultAmbientLightColor, - constants.defaultDirectionalLightColor, 1, - constants.defaultSunColor, - constants.maxSunDistance, - 1, constants.defaultRayColor); - break; - case 'afternoon': - changePreset( - new Color(0x40829c), - new Color(0x51411f), - new Color(0x948161), 1, - new Color(0xe1f8fe), - constants.maxSunDistance, - 1.5, new Color(0x3e6a8e)); - break; - case 'sunset': - changePreset( - new Color(0xc39737), - new Color(0x3e3418), - new Color(0x3e3418), 1, - new Color(0xff9242), - 50, - 2, new Color(0x873636)); - break; - case 'night': - changePreset( - new Color(0x252837), - new Color(0x201f2e), - new Color(0x2c2d44), 0.4, - new Color(0x8282c7), - constants.maxSunDistance, - 0.8, new Color(0x111122)); - break; - case 'eclipse': - changePreset( - new Color(0x140505), - new Color(0x773131), - new Color(0x202010), 1, - new Color(0x222222), - constants.maxSunDistance, - 2, new Color(0x460c0c)); - break; - case 'fantasy': - changePreset( - new Color(0x874089), - new Color(0x40387a), - new Color(0x9a4747), 1, - new Color(0x874089), - constants.maxSunDistance, - 2, new Color(0x557799)); - break; - } - }); -} -``` -이제 GUI 항목을 추가해주세요. -많죠? 제가 다 하나하나 정한 값들입니다. -이걸 보면 `changePreset()` 함수 만들길 잘했다는 생각이 들 겁니다. - -### 7.5 프리셋 로직 구현 -이제 `update()` 함수로 가서 `fantasy` 프리셋의 **색상 변화 애니메이션**을 넣어봅시다. - -``` -function update() { - ~ - if (controls.presets == 'fantasy') { - const color = new Color(0x874089); - const wave = (Math.cos(fantasyTime) + 1) * 0.5; - - color.r = THREE.MathUtils.clamp(color.r * wave, 0, 1); - color.g = THREE.MathUtils.clamp(color.g * wave, 0, 1); - color.b = THREE.MathUtils.clamp(color.b * wave, 0, 1); - - controls.backgroundColor = color; - controls.sunColor = color; - - renderer.setClearColor(color, 1); - sunObject.material.color = color; - - fantasyTime += deltaTime; - fantasyTime = (fantasyTime >= Math.PI * 2) ? fantasyTime - Math.PI * 2 : fantasyTime; - } - ~ -} -``` -태양 움직이는 로직하고 약간 비슷하면서 다릅니다. -`cos()` 함수의 결과에 `1`을 더하는군요. 그럼 범위가 `0~2`가 되겠죠? -이걸 반으로 나누면 다시 `0~1` 범위로 좁혀집니다. -색상의 경우 **음수로 떨어질 수 없기 때문**에 이렇게 한 거예요. -그렇게 나온 값을 색상의 각 요소에 곱하면 끝입니다. -나머지는 움직임 로직과 같아요. 다만 속도를 지정하지 않아서 `PI * 2`초마다 한 싸이클이 반복됩니다. - -이제 실행해보세요. 잘 되나요? - -### 7.6 태양 오브젝트 GUI 추가 -마지막으로 태양 오브젝트를 바꿔보겠습니다! -`createGUI()` 함수에 이것만 추가해주시면 됩니다. - -``` -function createGUI() { - ~ - generalFolder.add(controls, 'sunObject', ['sphere', 'lisaSu', 'cat', 'none']).listen().onChange(function (value) { - const textureLoader = new THREE.TextureLoader(); - let planeGeometry; - let planeMaterial; - let planeTexture; - - scene.remove(sunObject); - - switch (value) { - case 'sphere': - sunObject = new THREE.Mesh( - constants.defaultSunObjectGeometry, - constants.defaultSunObjectMaterial); - sunObject.position.set( - controls.sunPositionX, - controls.sunPositionY, - controls.sunPositionZ); - sunObject.scale.multiplyScalar(40); - - scene.add(sunObject); - break; - case 'lisaSu': - planeTexture = textureLoader.load('https://miro.medium.com/proxy/0*pKN_ICbi7lzXWgcN.png'); - planeGeometry = new THREE.PlaneGeometry(175, 100); - planeMaterial = new THREE.MeshBasicMaterial({ - map: planeTexture - }); - - sunObject = new THREE.Mesh(planeGeometry, planeMaterial); - sunObject.position.set( - controls.sunPositionX, - controls.sunPositionY, - controls.sunPositionZ); - scene.add(sunObject); - break; - case 'cat': - planeTexture = textureLoader.load('https://upload.wikimedia.org/wikipedia/commons/thumb/b/bb/Kittyply_edit1.jpg/220px-Kittyply_edit1.jpg'); - planeGeometry = new THREE.PlaneGeometry(173, 115); - planeMaterial = new THREE.MeshBasicMaterial({ - map: planeTexture - }); - - sunObject = new THREE.Mesh(planeGeometry, planeMaterial); - sunObject.position.set( - controls.sunPositionX, - controls.sunPositionY, - controls.sunPositionZ); - scene.add(sunObject); - break; - } - - sunObject.material.color = controls.sunColor; - }); - ~ -} -``` -저는 `빛-사수`와 귀여운 `고양이`로 정했습니다. -~~이 프로그램은 **인텔 프로세서**에서 개발되었습니다~~ -내용은 수업에서 배운 텍스처 적용과 동일하므로 아실 거라 믿습니다. - -### 7.7 마치며 - -이걸로 **라이트 셰프트 튜토리얼**은 끝입니다!! - -모두들 즐거우셨나요??? -힘들다고요?? - -괜찮아요 저는 재밌었거든요!! - - - -**Three.js를 통한 WebGL**은 처음이었지만 -예전에 **다이렉트3D**를 공부한 적이 있어서 쉬울 줄 알았습니다만... - -**헉~~~** - - - -**이거 GLSL은 어떻게 쓰는거야??** - -놀라지 마세요~~ -원래 라이트 셰프트는 **컴퓨트 셰이더(Compute Shader)**라는 곳에서 -수천 개의 GPU 스레드로 돌려야 부하가 덜하는 그런 기능이랍니다!! - -안 물어봤다고요??? - - - -괜찮아요!! 여러분이 궁금하셨다는 거 다 알고 있어요~~ - -그럼 저는 1도 공부 안 한 **알고리즘**을 공부하러 이만!! - - - -**@))))))))** (툭) - -</details> +이 프로젝트가 렌더링에 진심인 저같은 학우들에게 많은 도움이 됐으면 좋겠습니다. -<mark>7장의 전체 스크립트는 [**여기**](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/blob/main/tutorial_data/scripts/script_7.js "Light shaft chapter 7 source code")에 있습니다.</mark> +## 라이선스 +**MIT License** +Copyright (c) 2022 Suhyeon Han -## Source & Referrence +## 소스 & 레퍼런스 ### 소스 * [Three.js webgl_depth_texture](https://github.com/mrdoob/three.js/blob/master/examples/webgl_depth_texture.html) @@ -1796,7 +137,7 @@ function createGUI() { * [Three.js webgl_postprocessing_unreal_bloom](https://github.com/mrdoob/three.js/blob/master/examples/webgl_postprocessing_unreal_bloom.html) * [Three.js webgl_postprocessing_godrays](https://github.com/mrdoob/three.js/blob/master/examples/webgl_postprocessing_godrays.html) -### 참고문헌 +### 레퍼런스 * Pope Kim. 셰이더 프로그래밍 입문. 한빛미디어, 2012 * Feinstein, Doron. HLSL Development Cookbook. Packt, 2013 -* Dirksen, Jos. Learning Three.js - the JavaScript 3D Library for WebGL (Second Edition). Packt, 2015 \ No newline at end of file +* Dirksen, Jos. Learning Three.js - the JavaScript 3D Library for WebGL (Second Edition). Packt, 2015