Skip to content
Snippets Groups Projects
Commit d90b976f authored by Suhyeon Han's avatar Suhyeon Han
Browse files

Fully completed

parent 2a946a75
Branches
No related tags found
No related merge requests found
# 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장: 기본 장면 렌더링
![Chapter 2 result](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/chapter_results/chapter_2.png "Chapter 2 result")
<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 조작
![Chapter 3 result](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/chapter_results/chapter_3.png "Chapter 3 result")
<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장: 렌더 타겟
![Chapter 4 result](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/chapter_results/chapter_4.png "Chapter 4 result")
## 주제 및 선정 이유
### 주제
제가 선정한 주제는 **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>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`로 렌더 타겟을 변경하는 줄을 주석 처리해보세요.
다시 잘 보이죠?
결과물이 잘 ~~안~~보인다면 제대로 하신겁니다. **짝짝짝짝!**
<summary>개발 과정 (펼치기/접기)</summary>
### 1일차
**1일차**에는 주제를 정한 뒤 간단히 기본 장면을 만들었습니다.
모두 수업에 나온 내용인지라 전혀 어렵지 않았습니다.
그런데 잠깐, 애초에 Three.js에서 HLSL 책에 나온 원리로 구현이 가능한지가 궁금했습니다.
저도 모르는 어떠한 제한사항으로 인해 만들지 못하는 구조일 경우 일찍 포기해야만 했기 때문입니다.
사실 라이트 셰프트 셰이더는 Direct3D에서는 컴퓨트 셰이더로 계산하는 방식입니다.
컴퓨트 셰이더란 GPU의 스레드 그룹을 직접 배치하고 사용하는 방식으로 후처리 또는 파티클 기법에 최적화된 방식이며, 계산 속도가 굉장히 빠릅니다.
하지만 Three.js로 컴퓨트 셰이더를 사용할 수 있을지 없을지 몰랐기 때문에 그냥 없는 것으로 가정했습니다.
그럼 이제 컴퓨트 셰이더 코드를 버텍스-프래그먼트 셰이더 코드로 변환할 수 있는지를 알아야 했습니다.
그래서 저는 아주 오래된 프로그램인 렌더몽키를 꺼냈습니다(그리고 셰이더 프로그래밍 입문 책도).
렌더몽키는 AMD에서 만든 DirectX 9 셰이더 작성 프로그램입니다.
DirectX의 버텍스-픽셀 셰이더가 WebGL의 버텍스-프래그먼트 셰이더에 대응되므로 여기서 잘 작동하면 별 탈 없이 작동될 터였습니다.
그렇게 렌더몽키에 잘 구현해봤더니 정상적으로 작동했습니다. 의외로 사양도 높지 않았습니다.
![Render Monkey result](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/references/render_monkey.png "Render Monkey result")
### 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>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장: 가중치 추출
![Chapter 5 result](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/chapter_results/chapter_5.png "Chapter 5 result")
<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)**를 이용하는 겁니다.
그 전에 먼저 **프로젝션 공간**을 이해해야 합니다. 사실 이해라 할 것도 없어요.
![Projection vs UV](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/references/projection_vs_uv.png "Projection vs UV")
그냥 직접 그려보았습니다.
그림을 보면 아시다시피 프로젝션 공간은 왼쪽과 아래쪽 끝이 `-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;
```
사용할 패스는 하나입니다.
하나일지라도 이펙트 컴포저는 꼭 필요하므로 넣어주세요.
## 완성 후 느낀 점
다사다난했던 개발 과정으로 한 30시간 이상을 투자한 듯 하지만, 오랜만에 개발이 정말 재밌었습니다.
거의 열흘 동안 놀지도 않고 만들었는데, 하루종일 게임을 하는 기분이었습니다(너무 바쁘게 몰입해서 그런 듯 합니다).
물론 튜토리얼 작성할 때랑 HTML로 변환할 때는 조금 지루했습니다.
추가로 초기 튜토리얼에 재밌는 장난이나 사진도 많았는데, MIT 라이선스라 많이 지웠습니다. 조금은 아쉽네요.
패스 생성 함수를 추가합시다.
여기서 이펙트 컴포저도 생성합니다.
```
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장: 라이트 셰프트
![Chapter 6 result](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/chapter_results/chapter_6.png "Chapter 6 result")
<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 라이트 셰프트 셰이더
제일 중요한 **라이트 셰프트 셰이더** 작성입니다.
일단 이걸 이해하려면 어떻게 빛 줄기를 만드는지 알아야 합니다.
먼저 그림부터 보도록 합시다.
![Ray example](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/references/ray_example.png "Ray example")
보면 태양으로부터 멀어질수록, 특히 오브젝트에 가려질수록 빛 줄기가 약해집니다.
빛 줄기의 강도는 당연히 프래그먼트 셰이더에서 계산해야겠지요?
그런데 프래그먼트 셰이더의 호출 단위는 프래그먼트, 즉 화면의 **픽셀당 한 번씩 실행**된다고 보시면 됩니다.
그럼 맨 아래쪽에 빛 줄기가 거의 닿지 않는 나무 부분을 봅시다.
이 녀석은 어떻게 자기가 자신보다 위에 있는 나뭇가지에 가려져있다는 사실을 알까요?
답은 바로 **현재 픽셀로부터 태양까지의 거리 벡터**를 이용하는 겁니다.
그 다음 구한 거리 벡터를 일정한 양으로 나눠서 그 나눠진 수만큼 각각 가중치 맵에서 가중치를 꺼냅니다.
그렇게 꺼낸 각각의 가중치에 점점 커지는 감쇄를 적용한 후, 그 값들을 모두 더해버리면 그게 그 픽셀의 빛 줄기의 양이 됩니다.
예...말로 설명하면 확실히 이해하기 난해하죠.
그래서 그림을 준비해봤습니다.
![Light Ray](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/references/light_shaft.png "Light Ray")
자, 여러분이 추출한 **가중치 텍스처**입니다.
`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);
}
```
맨 아랫줄에 추가해주세요.
휴... 드디어 다 했습니다. 실행하셔도 됩니다.
빛 줄기가 잘 보이시나요?
결과물이 잘 보인다면 제대로 하신겁니다. **짝짝짝짝짝짝!**
</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장: 꾸미기
![Chapter 7 result](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/chapter_results/chapter_7.png "Chapter 7 result")
<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;
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 마치며
이걸로 **라이트 셰프트 튜토리얼**은 끝입니다!!
모두들 즐거우셨나요???
힘들다고요??
괜찮아요 저는 재밌었거든요!!
![Naver blog emoji 1](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/emojis/emoji_1.png "Naver blog emoji 1")
**Three.js를 통한 WebGL**은 처음이었지만
예전에 **다이렉트3D**를 공부한 적이 있어서 쉬울 줄 알았습니다만...
**헉~~~**
![Naver blog emoji 4](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/emojis/emoji_2.png "Naver blog emoji 4")
**이거 GLSL은 어떻게 쓰는거야??**
놀라지 마세요~~
원래 라이트 셰프트는 **컴퓨트 셰이더(Compute Shader)**라는 곳에서
수천 개의 GPU 스레드로 돌려야 부하가 덜하는 그런 기능이랍니다!!
안 물어봤다고요???
![Naver blog emoji 2](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/emojis/emoji_3.png "Naver blog emoji 2")
괜찮아요!! 여러분이 궁금하셨다는 거 다 알고 있어요~~
그럼 저는 1도 공부 안 한 **알고리즘**을 공부하러 이만!!
![Naver blog emoji 3](https://git.ajou.ac.kr/shh1473/cg-tutorial/-/raw/main/tutorial_data/pictures/emojis/emoji_4.png "Naver blog emoji 3")
**@))))))))** (툭)
</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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment