반드시 서버 환경에서 실행하세요!
Web server for Chrome 확장 프로그램을 추천합니다...
라고 한때 말씀드렸었으나 이제는 지원되지 않습니다.
따라서 다른 방법으로 서버를 열거나, 아니면 시연 없이 튜토리얼만 보셔도 됩니다.
저는 Visual Studio의 node.js를 사용했습니다.
프로그램 제어:
1장. 주제 및 목표
2장. 기본 장면 렌더링
3장. 카메라와 GUI 조작
4장. 렌더 타겟
5장. 가중치 추출
6장. 라이트 셰프트
7장. 꾸미기
8장. 소스 & 레퍼런스
본 프로그램은 Visual Studio 2022의 node.js 프로젝트에서 개발되었습니다.
실행 및 테스트는 반드시 서버 환경에서 해주세요.
로컬 환경에서 실행하면 브라우저 보안상의 이유로 로컬 파일을 불러올 수 없습니다(텍스처, 모델, 스크립트 등)!
제가 선정한 주제는 라이트 셰프트(Light Shaft) 효과로, 광원 주변의 오브젝트 사이로 스며들어오는 빛 줄기의 구현입니다.
기존 Three.js 예제에서 수정한 결과물은 아니지만 비슷한 예제로 Godrays가 있습니다.
다만 Godrays 예제의 경우 셰이더 패스가 많고 과정이 복잡하여 필요없는 부분(FakeSun 패스, Combine 패스)을 빼고, 셰이더 코드도 더 쉽도록 새로 만들었습니다.
즉, 백지 스크립트에서 제작되었으며 튜토리얼 또한 백지 스크립트에서 시작합니다.
1. 기본적인 렌더링 장면 구현하기
2. OrbitControls를 사용한 카메라의 움직임 구현하기
3. 렌더 타겟과 영상 처리에 대해 이해하기
4. EffectComposer와 ShaderPass 사용법 이해하기
5. GLSL 프로그래밍 과정 및 문법 이해하기
6. 깊이 텍스처로부터 가중치 추출하기
7. 라이트 셰프트 셰이더 만들기
2장에서는 베이스 프로그램을 만들겁니다.
index.html
의 경우 내용이 적고 자바스크립트가 아니므로 따로 해설을 하지는 않고 파일 첨부만 하겠습니다.
추가로 저처럼 node.js
로 서버를 여시는 분들을 위해 server.js
파일도 함께 제공하겠습니다.
index.html
코드는 여기에 있습니다.
server.js
코드는 여기에 있습니다.
자, 이제 본격적으로 script.js
파일을 만들어 봅시다.
기본적인 장면만 구성해볼 건데요.
대충 바닥 있고 나무 몇 개 있고 라이트 좀 있는 그런 장면을 만들도록 하겠습니다.
대부분이 수업에 나온 내용이므로 가볍게 따라올 수 있을 겁니다.
먼저 빈 script.js
파일을 준비해주세요.
가장 먼저 해야할 것은 모듈 불러오기입니다.
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
파일을 불러올 겁니다.
다음으로 할 일은 프로그램에서 사용할 상수의 정의입니다.
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 // 나무들의 간격 }; |
하나하나 설명하기엔 간단해서 주석으로 설명을 대신합니다.
기본 장면이지만 대부분의 상수가 기본 값이기 때문에 많이 들어가네요.
다음은 이제 실행 함수의 차례입니다.
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
키워드로 선언합니다.
이제 렌더러를 생성할 시간입니다.
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); |
마지막으로 컨테이너에 추가해주면 렌더러는 준비 완료입니다!
카메라를 생성해봅시다.
이전처럼 함수로 분리합니다.
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); |
마지막으로 씬에 넣어줍니다.
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
에서 최대 거리 값을 넣어줍니다.
씬에 넣는것도 잊지 마시고요.
마지막으로 오브젝트 배치입니다.
만들어야 할 오브젝트는 총 세 종류인데요.
각각 바닥, 태양, 나무입니다.
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
라는 오브젝트 로더를 생성합니다.
그리고 흰색 재질을 생성하는데요, 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
도 돌려줘야 합니다.
돌릴 때에는 라디안 단위를 사용하므로 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번과 2번만 사용하면 되므로 3번 4번은 무시하셔도 됩니다.
2번 함수의 경우 인자로 tree
를 받죠? 이게 생성된 오브젝트입니다.
그런데 우리는 흰색 재질을 나무에 적용시키고 싶으므로 함수 내부에서 값을 수정해줍니다.
위치는 x
의 경우 스마트하게 머릿속으로 계산해낸 공식을 사용하시면 되고요.
y
는 그대로 0
, z
는 -100~100
범위의 랜덤 값을 지정해줍니다(Math.random()
함수는 -1~1
범위의 랜덤 값을 반환합니다).
각도도 y
축으로 0~360
도 범위의 랜덤 값으로 지정합니다.
크기는 100
을 곱해줍니다.
이후 씬에 추가하면 나무도 끝!
이제 방금까지 만들었던 생성 함수들을 let
선언 바로 아래에 쭉 호출해줍니다(작성한 순서대로).
그리고 실행을 위해 마지막으로 해줘야 할 것이 있죠.
바로 매 프레임마다 호출되는 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()
함수를 호출해주도록 합시다.
거의 다 됐습니다!
이제 크기 조절 함수만 만들면 실행해볼 수 있습니다.
function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } |
크기가 바뀌면 콜백되는 함수입니다.
먼저 카메라 종횡비를 다시 설정하고 프로젝션 행렬을 재계산합니다.
렌더러는 렌더 영역의 크기를 새로 업데이트합니다.
window.addEventListener('resize', onWindowResize); |
그리고 이 호출 구문을 animate()
함수 호출 아래에 놓아줍시다.
이러면 화면 크기가 바뀌어도 정상적으로 크기가 업데이트되어 제대로 렌더링할 수 있습니다.
마지막으로 스크립트 맨 아래에 run()
함수를 호출해줍니다.
음침한 황무지같은 풍경이 잘 보이시나요?
결과물이 잘 보인다면 제대로 하신겁니다.
2장의 전체 스크립트는 여기에 있습니다.
3장에서는 카메라 조작을 구현하고, GUI를 추가해서 세부적인 사항을 조절할 수 있도록 할 겁니다.
사실 카메라의 경우 Three.js에서 OrbitControls
라는 날로먹는 모듈을 제공해주기 때문에 굉장히 짧고 쉬울 예정입니다.
OrbitControls
에 대해 간단히 설명드리자면, 생성해서 카메라만 넣어주면 마우스로 카메라를 아주 손쉽게 조작할 수 있게 해주는 모듈입니다.
피봇(LookAt 위치)을 중심으로 궤도를 돌듯 카메라를 이동시키므로 OrbitControls
라는 이름이 붙은 듯 합니다.
먼저 2장에서 추가한 모듈 리스트에 GUI
와 OrbitControls
모듈을 추가해 줍시다.
import { GUI } from './jsm/libs/lil-gui.module.min.js'; import { OrbitControls } from './jsm/controls/OrbitControls.js'; |
이렇게요.
OrbitControls에 필요한 상수도 조금 추가해보도록 하겠습니다.
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
로 했다간 범위 밖으로 튀어나갈 수도 있습니다.
각각이 무엇을 뜻하는지는 주석을 참고하세요.
먼저 OrbitControls를 먼저 생성하고 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
는 OrbitControls을 사용할지 여부인데 저는 기본 값으로 false
를 주었습니다.
시작했을 땐 위를 바라보게 하기 위함입니다.
여기서 중요한 부분이 있는데, OrbitControls을 활성화하면 카메라는 반드시 OrbitControls에서 지정한 별도의 LookAt의 위치를 바라보게 됩니다.
즉, 우리는 기본 값으로 (0, 70, 0)
을 지정했지만 OrbitControls을 생성했기 때문에 카메라의 LookAt 값은 자동으로 (0, 0, 0)
으로 바뀌어버립니다.
그러므로 카메라의 LookAt 위치를 다시 기본 값으로 수정합니다.
추가로 createCamera()
함수 내에서 LookAt 위치를 설정하는 구분은 이제 지우셔도 됩니다.
이제 GUI를 만들 차례입니다.
지금부터 작성하는 코드는 모두 createGUI()
를 만들어 안에 작성하면 됩니다.
function createGUI() { 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
인데요. 이걸 활성화하면 OrbitControls이 활성화됩니다.
활성화를 표현하기 위해서는 체크박스가 필요하고, 체크박스는 슬라이더에서 최솟값과 최댓값 자리를 비우면 만들 수 있습니다.
내용을 보면 일단 true/false
상관 없이 카메라의 위치를 기본 위치로 바꿉니다.
그리고 true
라면 LookAt 위치를 원점으로, false
라면 기본 위치로 바꿉니다.
끝으로 OrbitControls의 enabled
값을 value
로 설정해주면 끝입니다!
시야각의 경우 40~80
범위를 갖는 슬라이더이며 카메라의 fov
값 수정 후 updateProjectionMatrix()
함수를 호출해줍니다.
이걸 해 주지 않으면 바꿔도 변화가 없으므로 주의하세요.
이제 GUI도 끝났습니다.
실행해보면 GUI를 조작해 카메라 및 조명 등 많은 것을 조절할 수 있는 것을 확인할 수 있습니다.
결과물이 잘 보인다면 제대로 하신겁니다.
3장의 전체 스크립트는 여기에 있습니다.
이제부터 약간의 원리 이해가 필요합니다.
먼저 렌더 타겟에 대해 설명드리도록 하겠습니다.
렌더 타겟이란 렌더링의 대상을 말합니다. 즉, 정육면체를 렌더했으면 그것은 렌더 타겟이라는 버퍼에 그려지게 됩니다.
근데 생각해보니 우린 이제까지 렌더 타겟을 지정해준 적이 없죠? 화면에는 잘 나오던데, 왜 그럴까요?
답은 기본 렌더 타겟이 있기 때문입니다. 렌더 타겟에는 기본 렌더 타겟이 있고 사용자 지정 렌더 타겟이 있는데요.
우리는 지금까지 별도로 렌더 타겟을 만든 적이 없으니 그대로 기본 렌더 타겟에 그려지고 있던 겁니다.
하지만 지금부터는 별도의 렌더 타겟이 필요합니다.
추가적인 렌더 타겟이 왜 필요한지도 알아야겠죠? 답은 영상 처리의 원리에 있습니다.
영상 처리란 오브젝트를 전부 그린 버퍼를 후처리(Post-processing)하는 작업을 말합니다.
예를 들어 장면 전체를 흑백 화면으로 모니터에 출력하려면 어떻게 하면 될까요?
모든 오브젝트의 재질 색을 흑백으로 바꾸는 건 현명하지 않겠죠?
그럼 그냥 컬러로 모든 오브젝트를 그리고, 이후 그 렌더 타겟을 따로 빼서 흑백 처리를 해주면 됩니다.
이러한 일종의 이미지 필터링/가공 작업을 영상 처리라고 부릅니다.
라이트 셰프트를 구현하기 위해서는 장면의 모든 오브젝트가 그려진 렌더 타겟에서, 오브젝트가 없는 부분을 추출해야만 합니다.
왜인지는 빛 줄기를 표현하는 원리에서 알 수 있는데, 복잡하므로 지금은 설명하지 않겠습니다. 천천히 6장까지 보시면 알 수 있습니다.
따라서 일단은 새로운 렌더 타겟을 하나 만들고 거기에 모든 오브젝트를 그려보는 것에 집중해 봅시다.
먼저 선언부터 해볼까요.
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()
함수 바로 아래에서 호출해주시면 됩니다.
그럼 이제 장면을 여기에 렌더하도록 바꿔봅시다.
렌더 타겟을 변경하는 방법도 엄청 쉽습니다.
animate()
함수의 render()
함수로 가봅시다.
function render() { renderer.setRenderTarget(originalRT); renderer.render(scene, camera); renderer.setRenderTarget(null); } |
render()
함수 위 아래에 setRenderTarget()
함수를 호출해주면 됩니다.
인자는 각각 originalRT
와 null
인데요.
originalRT
는 방금 만든 렌더 타겟이고, null
은 기본 렌더 타겟입니다(null을 넣어주면 알아서 그렇게 바꿔줍니다).
즉, originalRT
에 렌더하고 렌더 후 기본 렌더 타겟으로 다시 되돌려놓는 겁니다.
되돌려 놓는 것이 필수는 아니지만 그렇게 해놓으면 깔끔하고 좋습니다.
렌더 타겟은 크기가 정해진 텍스처와 같습니다.
그렇다는 말은 창의 크기가 변경되면 렌더 타겟의 크기도 업데이트해줘야 한다는 겁니다.
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
로 렌더 타겟을 변경하는 줄을 주석 처리해보세요.
다시 잘 보이죠?
주석 처리한 부분은 다시 원래대로 되돌려놔야 합니다.
결과물이 잘 안보인다면 제대로 하신겁니다.
4장의 전체 스크립트는 여기에 있습니다.
5장부터는 꽤 어려워지기 시작합니다.
아마 모르거나 처음 보는 내용이 많을 겁니다.
일단 모듈부터 추가해보죠.
이 프로그램에 추가될 마지막 모듈 둘입니다.
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장에서 다시 보게 될 겁니다.
모듈도 불러왔고 하니 이제 가중치를 저장해놓을 렌더 타겟을 추가로 선언해봅시다.
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장에서 가장 어려운 파트입니다.
셰이더를 직접 짜보는 것이죠.
만들 셰이더는 originalRT
의 깊이 텍스처를 가지고 가중치를 계산하는 겁니다.
이 가중치는 라이트 셰프트의 강도를 의미합니다.
이를 바탕으로 그 픽셀에 들어갈 라이트 셰프트의 강도가 정해집니다.
그럼 이제 장면의 어느 부분이 가장 가중치가 높을 지를 생각해 봅시다. 정답은 오브젝트가 없는 부분입니다.
게임이나 현실에서 빛 줄기를 보면, 오브젝트가 없으면 완전 진하다가 오브젝트에 닿으면 점점 감쇄가 되는 걸 확인할 수 있습니다.
그래서 빛 줄기는 오브젝트가 없는 곳으로는 쭉 뻗어나가게 됩니다. 가중치가 1에 가깝기 때문에 곱해도 본연의 값을 유지하기 때문입니다.
그러다가 오브젝트를 만나면 점점 감쇄되다가 이내 사라집니다.
즉, 오브젝트가 없는 부분엔 특정 가중치를 주고, 없는 부분은 0
에 가까운 값으로 넣어버리면 됩니다.
그러면 오브젝트가 있는지 없는지는 어떻게 알아낼까요? originalRT
를 보고 검은색은 오브젝트가 없는 것으로 보면 될 듯 하지만 틀렸습니다.
그런다면 검은색을 가진 오브젝트는 분명 존재함에도 불구하고 없는 것으로 처리되고 말 겁니다.
따라서 색상이 아닌 다른 무언가를 보고 오브젝트가 있는지 없는지를 판단해야 합니다.
그래서 우리가 사용할 값은 깊이입니다.
깊이 텍스처는 색상에 관계없이 오브젝트가 없으면 1
, 있으면 카메라에 얼마나 가깝냐에 따라 0~1
까지의 값을 넣어줍니다.
카메라에 가까울수록 값은 0
에 가까워집니다. 그럼 이제 어떻게 해야할지 아시겠나요?
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
를 대입하면 됩니다.
아마 검은색과 어두운 회색이 섞인 느낌의 텍스처가 탄생할 겁니다.
조금 힘들었지만 어떻게든 셰이더를 짜냈습니다!
이제 이걸 사용해줄 패스와 이펙트 컴포저를 생성하러 가봅시다.
다시 쉬운 파트입니다.
먼저 선언부터 하겠습니다.
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()
함수 아래에서 호출하도록 해주시면 되겠습니다.
실행하기 전에 손 볼 부분이 조금 있습니다.
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
로 하는 줄을 주석처리하면 됩니다.
그럼 이제 가중치 텍스처를 볼 수 있게 됩니다.
확인했으면 4장 처럼 다시 되돌리는 것을 잊지 맙시다.
결과물이 마찬가지로 잘 안보인다면 제대로 하신겁니다.
5장의 전체 스크립트는 여기에 있습니다.
이제 라이트 셰프트를 완성할 차례입니다.
그 전에 상수부터 조금 추가해주겠습니다.
const constants = { ~ defaultRayLength: 1, defaultRayColor: new Color(0x444444), rayLengthMultiplier: 24, ~ }; |
이렇게 세 개를 넣어줍시다.
빛 줄기의 길이와 색, 길이 배수입니다.
길이 배수는 나중에 설명하도록 하겠습니다.
다음은 컨트롤 변수를 추가합시다.
const controls = { ~ rayLength: constants.defaultRayLength, rayColor: constants.defaultRayColor, ~ }; |
constants
에 있는 값 그대로 넣어주시면 됩니다.
제일 중요한 라이트 셰프트 셰이더 작성입니다.
일단 이걸 이해하려면 어떻게 빛 줄기를 만드는지 알아야 합니다.
먼저 그림부터 보도록 합시다.
보면 태양으로부터 멀어질수록, 특히 오브젝트에 가려질수록 빛 줄기가 약해집니다.
빛 줄기의 강도는 당연히 프래그먼트 셰이더에서 계산해야 합니다.
그런데 프래그먼트 셰이더의 호출 단위는 프래그먼트, 즉 화면의 픽셀당 한 번씩 실행된다고 보면 됩니다.
그럼 맨 아래쪽에 빛 줄기가 거의 닿지 않는 나무 부분을 봅시다.
이 프래그먼트는 어떻게 자기가 자신보다 위에 있는 나뭇가지에 가려져있다는 사실을 알까요?
이는 현재 픽셀로부터 태양까지의 거리 벡터를 이용하면 됩니다.
그 다음 구한 거리 벡터를 일정한 양으로 나눈 뒤, 그 나눠진 수만큼 각각 가중치 맵에서 가중치를 꺼냅니다.
그렇게 꺼낸 각각의 가중치에 점점 커지는 감쇄를 적용한 후, 그 값들을 모두 더해버리면 그게 그 픽셀의 빛 줄기의 양이 됩니다.
역시 말로 설명하면 이해하기 난해하죠.
그래서 또 그림을 준비해봤습니다.
자, 여러분이 추출한 가중치 텍스처입니다.
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 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; }` |
프래그먼트 셰이더가 어려울 수 있어서 주석을 넣었습니다.
어려운 부분만 다시 보도록 합시다.
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
범위로 맞춰줍니다.
이후 최종 결과 값을 출력하면 셰이더가 완성됩니다!
이제 패스를 만들어볼까요.
먼저 선언부터 해야지요.
let lightShaftComposer; let lightShaftPass; |
이렇게 두 개를 추가해줍니다.
weightComposer
에 lightShaftPass
를 추가해주는 건 안 됩니다.
해보니 컴포저는 하나의 렌더 타겟만 받는 것 같습니다.
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
형이니까요.
다 끝나면 역시 이펙트 컴포저에 추가해줍니다.
이제 태양의 위치를 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
에 직접 수정해주면 완성입니다.
그럼 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
가 모두 필요합니다.
거의 다 됐습니다. 이제 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()
함수에 추가해주세요.
이펙트 컴포저가 늘었으니 이것도 해줘야 합니다.
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); } |
맨 아랫줄에 추가해주세요.
드디어 다 했습니다. 이제 실행하셔도 됩니다.
빛 줄기가 잘 보이나요?
결과물이 잘 보인다면 제대로 하신겁니다.
6장의 전체 스크립트는 여기에 있습니다.
7장부터는 옵션입니다.
그러므로 해도 되고 안 해도 됩니다.
추가할 기능은 다음과 같습니다.
상수부터 추가합니다.
const constants = { ~ autoSunMoveSpeed: 0.5, autoCameraMoveSpeed: 0.3, ~ } |
이렇게 두 개 넣어주시면 됩니다.
각각 태양과 카메라의 자동 이동 속도입니다.
다음은 컨트롤 변수입니다.
const controls = { ~ presets: 'default', sunObject: 'Sphere', autoSunMove: false, autoCameraMove: false, ~ } |
이렇게 추가해주세요.
presets
와 sunObject
는 목록용이고, autoSunMove
와 autoCameraMove
는 체크박스용입니다.
체크박스를 만들어봅시다.
바꿔줄 게 딱히 없어서 코드가 매우 짧습니다.
function createGUI() { ~ generalFolder.add(controls, 'autoSunMove').listen(); generalFolder.add(controls, 'autoCameraMove').listen(); ~ } |
이렇게 두 줄만 넣어주시면 됩니다.
onChange()
함수도 필요 없습니다.
이제 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; } |
카메라의 움직임도 똑같습니다.
범위랑 속도만 조금 다를 뿐입니다.
이제 실행해보세요.
체크박스를 조작하면 태양과 카메라가 잘 움직일 겁니다.
이제 프리셋 기능을 만들어봅시다.
먼저 변수 하나를 넣어줘야합니다.
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 항목을 각각 추가해주세요.
색상 값들은 제가 다 하나하나 정한 값들입니다.
이제 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
초마다 한 싸이클이 반복됩니다.
이제 실행해봅시다.
마지막으로 태양 오브젝트를 바꿔보겠습니다!
createGUI()
함수에 이것만 추가해주시면 됩니다.
function createGUI() { ~ generalFolder.add(controls, 'sunObject', ['sphere', 'sun_draw', 'cat_draw', '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 'sun_draw': planeTexture = textureLoader.load('sprites/sun.png'); planeGeometry = new THREE.PlaneGeometry(90, 90); planeMaterial = new THREE.MeshBasicMaterial({ map: planeTexture, alphaTest: 0.05 }); sunObject = new THREE.Mesh(planeGeometry, planeMaterial); sunObject.position.set( controls.sunPositionX, controls.sunPositionY, controls.sunPositionZ); scene.add(sunObject); break; case 'cat_draw': planeTexture = textureLoader.load('sprites/cat.png'); planeGeometry = new THREE.PlaneGeometry(90, 90); planeMaterial = new THREE.MeshBasicMaterial({ map: planeTexture, alphaTest: 0.05 }); sunObject = new THREE.Mesh(planeGeometry, planeMaterial); sunObject.position.set( controls.sunPositionX, controls.sunPositionY, controls.sunPositionZ); scene.add(sunObject); break; } sunObject.material.color = controls.sunColor; }); ~ } |
저는 직접 그린 태양
과 직접 그린 고양이
로 정했습니다.
재질에 텍스처를 적용하는 내용은 수업에서 배운 텍스처 적용과 동일하므로 아실 거라 믿습니다.
참고로 alphaTest
값은 알파 클립 값을 나타냅니다. 즉, 저 값보다 작은 알파 값은 투명한 것으로 처리합니다.
저걸 설정해주지 않으면 투명도가 없는 것으로 처리되니 꼭 넣어주시기 바랍니다.
이걸로 꾸미기까지 모두 완성입니다.
결과물이 잘 보인다면 제대로 하신겁니다.
끝까지 수고 많았습니다!
7장의 전체 스크립트는 여기에 있습니다.