개요
요즘 한번도 배운적 없던 THREE.js 를 사용해 프로젝트를 진행해 보게 되어 배울것도 이해해야 될 것도 많아져 정신 없긴한데요. 또 그만큼 새로운 기술에 대해 공부한다는 생각에 재밌기도 합니다. 무엇보다 3D 환경에 동적인 면이 신기하기도, VR 기기 같은 여러 인풋에 대한 상황이 흥미롭습니다. 이번 글은 프로젝트에 사용될 기능 중 하나인 그림 그리기에 대해 공부하고 서핑한 것들에 대해 정리한 내용을 담고 있습니다. THREE.js 의 상세한 내용은 담지 않았습니다.
추가로, THREE.js 의 버전이 정말 빠르게 바뀌는 것 같습니다.
몇 달전 내용이 최신 버전에서는 꽤나 다르거나 사라진 함수/객체가 많더군요.
아래 코드는 r135 버전에서 작동됩니다.
코드 결과

기본 설정
제일 처음으로 씬과 카메라, 렌더러를 생성합니다.
import * as THREE from "three";
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 1000);
camera.position.set(0, 0, 10);
var renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
OrbitControls 추가
마우스를 이용해 화면을 이동시키기 위해 OrbitControls 을 추가합니다.
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
var controls = new OrbitControls(camera, renderer.domElement);
Raycaster 추가
카메라가 바라보는 화면으로 raycast를 발사하여 3d 상에 마우스 위치를 받아옵니다.
Raycaster 를 사용하는 이유는 뒤에서 언급하겠습니다.
var raycaster = new THREE.Raycaster();
// 마우스 좌표(위치) 저장
var mouse = new THREE.Vector2();
var point = new THREE.Vector3();
plane 생성
마우스 위치를 그대로 받아와 3d 에서 사용하기 힘듭니다.
카메라의 위치, 시야각, 각도 등의 환경에 따라 변경되기 때문인데요.
그래서 사용하는 것이 plane 입니다.
앞서 추가한 raycaster 와 plane 을 이용해 뒤에서 마우스가 가리키는 위치를 받아오겠습니다.
var plane = new THREE.Plane();
var planeNormal = new THREE.Vector3();
이벤트 리스너 생성
마우스 이동과 키보드 이벤트를 받을 리스너를 추가합니다.
일반적으로 그림을 그릴때 마우스 클릭 + 이동으로 입력을 받지만, 앞에서 OrbitControls 를 사용해 마우스 클릭은 카메라 이동으로 작동하게 만들었습니다.
고정된 화면에서 그림을 그리고 싶을때가 대부분이기 때문에 키보드의 스페이스바를 누르는 것으로 마우스 클릭을 대신하겠습니다.
추가로 그린 그림을 취소하는 뒤로가기(Ctrl + Z) 상황도 고려하겠습니다.
document.addEventListener("mousemove", event => {
// 마우스 이동
//- 그림 그릴때 이동하고 있는 마우스의 현재 좌표를 받아옵니다.
});
document.addEventListener("keydown", event => {
// 키보드 누르기
//- 마우스 위치를 따라 그림을 그리고자 할때 스페이스바를 누릅니다.
//- 그린 그림을 취소하고 싶을때 Ctrl+Z 를 입력 받습니다.
});
document.addEventListener("keyup", event => {
// 키보드 떼기
//- 스페이스바에서 손을 떼었을때 그림 그리기를 해제합니다.
});
render
렌더링 합니다.
function render() {
requestAnimationFrame(render);
renderer.render(scene, camera);
}
render();
그림 그리기를 위한 변수
그림 그리기를 위해 필요한 변수들을 생성합니다.
let isDrawing = false;
let myLines = [];
let drawCount = 0;
const MAX_POS_LENGTH = 100000;
isDrawing: 그림 그리는 중임을 확인합니다. 스페이스바를 누르고 있을때 true가 됩니다.myLines: 그린 그림들을 저장합니다. 뒤로가기를 눌렀을때 스택형식으로 삭제됩니다.drawCount: 0부터 N 번째 좌표까지 그림을 그린다는 의미로 변수를 저장합니다.MAX_POS_LENGTH: 최대 N 개의 좌표를 저장할지를 의미합니다. 수가 적을수록 그림을 오래 그릴 수 없습니다.
마우스 좌표 위치 받아오는 함수
현재 마우스가 바라보고 있는 위치가 3D 상에서 어떤 좌표에 해당하는지를 받아오는 함수를 생성합니다.
앞서 OrbitControls 을 사용하지 않고 고정 화면에서 그림을 그리고자 한다면 마우스 위치를 받아오는 방법은 간단합니다.
아래와 같이 마우스 이동에 따른 event 를 받아와 위치를 받고,
unproject를 이용해 카메라의 정규화된 좌표 공간에서 월드(표준) 공간으로 변경시키면 되기 때문입니다.
function getMousePosition(event) {
// 화면에서 마우스 위치 받아오기
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// ! 항상 z 좌표라는 문제와 카메라 앵글이 변하면 x와 y좌표가 정확하지 않음
var point = new THREE.Vector3(mouse.x, mouse.y, 0);
point.unproject(camera);
}
하지만 카메라를 움직이고 바라보는 방향에 따른 그림을 그리고 싶다면 위 방법만으로는 조금 부족합니다.
OrbitControls로 카메라의 위치와 앵글을 변환 시켰을때 원하는 좌표값을 얻어올 수 없기 때문입니다.
function getMousePosition(event) {
// 화면에서 마우스 위치 받아오기
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// plane 이용해서 마우스 위치를 3d 에서 얻어오기
planeNormal.copy(camera.position).normalize();
plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(plane.normal), scenePosition);
// raycaster
raycaster.setFromCamera(mouse, camera);
raycaster.ray.intersectPlane(plane, saveHere); // saveHere에 실제 좌표가 저장됩니다.
}
planeNormal 에는 카메라 위치의 단위 벡터(unit vector)값이 들어갑니다. 즉, 카메라 위치의 방향은 같지만 길이가 1인 벡터입니다.
plane의 평면 속성을 planeNormal과 scene 위치와의 동일 평면 점으로 설정합니다.
intersectPlane 의 두번째 인자에 raycaster로 확인된 좌표가 복사됩니다.
말로서는 조금 복잡하지 몰라도 아래 사진과 같다고 생각하면 편리합니다.

카메라 위치와 앵글이 변해도 plane 역시 카메라를 따라가기 때문에 항상 원하는 x,y,z 값을 구할 수 있습니다.
마우스 좌표 저장하는 함수
앞에서 마우스 좌표를 받아왔다면 해당 좌표를 저장하는 함수를 만들겠습니다.
function addPosition() {
// 현재 그리고 있는 line의 position 속성값
const positions = myLines[myLines.length - 1].geometry.getAttribute("position");
// 마우스 좌표 추가
positions.array[drawCount++] = point.x;
positions.array[drawCount++] = point.y;
positions.array[drawCount++] = point.z;
// 최대 배열 크기 안넘어가게 방지
drawCount %= MAX_POS_LENGTH;
// 0 ~ N(마우스가 이동한 좌표들 개수)개 까지 화면에 그림
myLines[myLines.length - 1].geometry.setDrawRange(0, drawCount / 3);
myLines[myLines.length - 1].geometry.getAttribute("position").needsUpdate = true;
}
THREE.LINE 에는 setDrawRange 함수가 있습니다.
함수 명명 그대로 특정 범위까지의 좌표를 그린다는 의미입니다.
마우스를 이동시킬때마다 좌표가 변경될텐데요. 그때마다 추가되는 좌표를 myLines의 마지막 line 에 추가합니다.
그리고 setDrawRange 를 이용해 0 부터 N개의 좌표까지 그림을 그린다고 명시하는 겁니다.
drawCount 는 좌표 x,y,z 마다 증가되기 때문에 N개의 좌표는 drawCount / 3을 의미합니다.
마지막으로 THREE.LINE 의 geometry에서 position 속성에 needsUpdate 를 true 로 변경해 주어야 추가된 좌표가 업데이트 되어 그려집니다.
마우스 이동 이벤트
앞에서 추가한 mousemouve 이벤트 리스너를 다음과 같이 변경합니다.
isDrawing 은 현재 그림을 그리고자 하는 상황을 의미합니다.
getMousePosition 함수에서 마우스의 좌표값을 받아온 후 point 변수에 저장되고 addPosition 함수에서 해당 좌표를 마우스가 이동한 좌표 배열에 추가하는 방식입니다.
document.addEventListener("mousemove", event => {
getMousePosition(event);
if (isDrawing) addPosition();
});
getMousePosition 을 if문 안으로 넣어도 됩니다. 상황에 따라 변경하시면 되겠습니다.
키 입력 이벤트
이번에는 스페이스바 입력과 Ctrl+Z 입력을 위해 keydown 이벤트 리스너를 변경합니다.
let keysPressed = {}; // 키 다중 입력 처리용
document.addEventListener("keydown", event => {
let key = event.key || event.keyCode;
keysPressed[key] = true;
if ((key === " " || key === 32) && !isDrawing) {
isDrawing = true;
drawCount = 0;
var geometry = new THREE.BufferGeometry();
var positions = new Float32Array(MAX_POS_LENGTH * 3); // (x,y,z 총 3개가 담기기 때문에 * 3)
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setDrawRange(0, drawCount);
var material = new THREE.LineBasicMaterial({
color: "white",
linewidth: 4,
});
myLines.push(new THREE.Line(geometry, material));
scene.add(myLines[myLines.length - 1]);
}
if (keysPressed["Control"] && event.key == "z" && !event.repeat) {
if (myLines.length) scene.remove(myLines.pop());
}
});
Ctrl+Z 기능을 위해서 키 다중 입력을 처리해야 하는데요. 만약 필요없다 싶으면 keysPressed 부분은 모두 제거하면 됩니다.
좌표를 저장하기 위한 positions 함수의 길이는 x,y,z 좌표 3개를 담기 위해 3을 곱해줍니다.
(각각의 좌표를 저장하기 위해 Float32Array 를 사용하게 됩니다만 차라리 편리하게 VectorArray 같은걸 만들어서 한번에 쉽게 저장할 수 있으면 더 좋았을텐데 아쉽습니다.)
그려지는 선의 특징을 LineBasicMaterial 에서 변경하면 되겠습니다.
키 떼기 이벤트
마지막으로 키 입력에서 손을 떼었을때 즉, 키 입력을 마쳤을때의 keyup 이벤트 리스너를 변경합니다.
document.addEventListener("keyup", event => {
let key = event.key || event.keyCode;
delete keysPressed[key];
if (key === " " || key === 32) {
isDrawing = false;
}
});
코드 전체
마치며
여러모로 삽질이 조금 많았습니다. 마우스 위치를 제대로 받지 못하는 문제부터 좌표를 계속해서 못받아 오면서 선이 끊어 지는 등, MeshLine 같은 것도 추가해서 사용해보았지만 위 방법과 같이 마우스 좌표를 계속해서 추가해 주는 것이 제일 깔끔했습니다.
물론 위 방법도 최고의 방법이라고 하기에는 조금 부족함이 있습니다.
MAX_POS_LENGTH 변수의 값에 따라 선의 길이가 정해진다는 점인데요.
너무 큰 값으로 설정하자니 짧게 그린 선들에게는 쓸데없이 배열의 크기가 커지는 점이 걸립니다.
일단 가장 간단해 보이는 방법으로는 배열의 크기가 꽉 찼을때 새 선을 생성해주는 점이 좋아보입니다만 뒤로가기 기능에서 나뉘어 지워진다는 점이 조금 아쉽습니다.
만약 동적으로 배열 크기를 조절하는 방법을 찾게 되면 업데이트 하겠습니다.
추가 +
본문에서 추가합니다.
THREE.JS 에서 일반적으로 사용하는 Line 을 사용하기보다 THREE.JS/examples에서 제공하는 line2를 사용한다면 환경에 따라 선 굵기가 변하지 않던 문제를 해결할 수 있습니다.
본문에서의 선 길이가 길어짐에 따라 동적으로 설정해주고 싶다는 문제 역시 해결했는데요.
아래 코드들은 최대한 재사용 가능하게 정리해 놓았으니 필요하다면 참고하시면 좋을것같습니다.
본문과 크게 다른 내용은 Line이 아닌 Line2를 사용했다는 점이며,
Line2의 geometry 속성의 _maxInstanceCount 값을 수동으로 직접 바꿈으로서
배열 크기를 좌표가 커질때마다 동적으로 늘리는 점입니다.
틀린 내용이 있다면 지적해 주시고,
더 좋은 방법이나 생각을 공유해주세요.

