You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
653 lines
29 KiB
653 lines
29 KiB
2 years ago
|
<!DOCTYPE html><html lang="ko"><head>
|
||
|
<meta charset="utf-8">
|
||
|
<title>로 캔버스, 장면 여러 개 만들기</title>
|
||
|
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||
|
<meta name="twitter:card" content="summary_large_image">
|
||
|
<meta name="twitter:site" content="@threejs">
|
||
|
<meta name="twitter:title" content="Three.js – 로 캔버스, 장면 여러 개 만들기">
|
||
|
<meta property="og:image" content="https://threejs.org/files/share.png">
|
||
|
<link rel="shortcut icon" href="/files/favicon_white.ico" media="(prefers-color-scheme: dark)">
|
||
|
<link rel="shortcut icon" href="/files/favicon.ico" media="(prefers-color-scheme: light)">
|
||
|
|
||
|
<link rel="stylesheet" href="/manual/resources/lesson.css">
|
||
|
<link rel="stylesheet" href="/manual/resources/lang.css">
|
||
|
<!-- Import maps polyfill -->
|
||
|
<!-- Remove this when import maps will be widely supported -->
|
||
|
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
|
||
|
|
||
|
<script type="importmap">
|
||
|
{
|
||
|
"imports": {
|
||
|
"three": "../../build/three.module.js"
|
||
|
}
|
||
|
}
|
||
|
</script>
|
||
|
<link rel="stylesheet" href="/manual/ko/lang.css">
|
||
|
</head>
|
||
|
<body>
|
||
|
<div class="container">
|
||
|
<div class="lesson-title">
|
||
|
<h1>로 캔버스, 장면 여러 개 만들기</h1>
|
||
|
</div>
|
||
|
<div class="lesson">
|
||
|
<div class="lesson-main">
|
||
|
<p>사람들이 자주 하는 질문 중 하나는 Three.js로 여러 개의 캔버스(canvas)를 렌더링하려면
|
||
|
어떻게 해야 하나요?"입니다. 쇼핑몰 사이트나 3D 도표가 여러 개 있는 웹 페이지를
|
||
|
제작한다고 해봅시다. 얼핏 그리 어려울 건 없어 보입니다. 그냥 도표가 들어갈 곳마다
|
||
|
각각 캔버스를 만들고, 각 캔버스마다 <a href="/docs/#api/ko/constants/Renderer"><code class="notranslate" translate="no">Renderer</code></a>를 생성하면 되지 않을까요?</p>
|
||
|
<p>하지만 이 방법을 적용하자마자 문제가 생깁니다.</p>
|
||
|
<ol>
|
||
|
<li><p>브라우저의 WebGL 컨텍스트(context)는 제한적이다.</p>
|
||
|
<p> 일반적으로 약 8개가 최대입니다. 9번째 컨텍스트를 만들면 제일 처음에 만들었던
|
||
|
컨텍스트가 사라지죠.</p>
|
||
|
</li>
|
||
|
<li><p>WebGL 자원은 컨텍스트끼리 공유할 수 없다.</p>
|
||
|
<p> 10MB짜리 모델을 캔버스 두 개에서 사용하려면 모델을 각각 총 두 번 로드해야 하고,
|
||
|
원래의 두 배인 20MB의 자원을 사용한다는 의미입니다. 컨텍스트끼리는 어떤 것도 공유할
|
||
|
수 없죠. 또한 초기화도 두 번, 쉐이더 컴파일도 두 번, 같은 동작은 모두 두 번씩
|
||
|
실행해야 합니다. 캔버스의 개수가 많아질수록 성능에 문제가 생기겠죠.</p>
|
||
|
</li>
|
||
|
</ol>
|
||
|
<p>그렇다면 어떻게 해야 할까요?</p>
|
||
|
<p>방법 중 하나는 캔버스 하나로 화면 전체를 채우고, 각 "가상" 캔버스를 대신할 HTML 요소(element)를
|
||
|
두는 겁니다. <a href="/docs/#api/ko/constants/Renderer"><code class="notranslate" translate="no">Renderer</code></a>는 하나만 만들되 가상 캔버스에 각각 <a href="/docs/#api/ko/scenes/Scene"><code class="notranslate" translate="no">Scene</code></a>을 만드는 거죠. 그리고
|
||
|
가상 HTML 요소의 좌표를 계산해 요소가 화면에 보인다면 Three.js가 해당 장면(scene)을 가상
|
||
|
요소의 좌표에 맞춰 렌더링하도록 합니다.</p>
|
||
|
<p>이 방법은 캔버스를 하나만 사용하므로 위 1번과 2번 문제 모두 해결할 수 있습니다. 컨텍스트를
|
||
|
하나만 사용하니 WebGL 컨텍스트 제한을 걱정할 일도 없고, 자원을 몇 배씩 더 사용할 일도 없죠.</p>
|
||
|
<p>2개의 장면만 만들어 간단히 테스트를 해보겠습니다. 먼저 HTML을 작성합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-html" translate="no"><canvas id="c"></canvas>
|
||
|
<p>
|
||
|
<span id="box" class="diagram left"></span>
|
||
|
I love boxes. Presents come in boxes.
|
||
|
When I find a new box I'm always excited to find out what's inside.
|
||
|
</p>
|
||
|
<p>
|
||
|
<span id="pyramid" class="diagram right"></span>
|
||
|
When I was a kid I dreamed of going on an expedition inside a pyramid
|
||
|
and finding a undiscovered tomb full of mummies and treasure.
|
||
|
</p>
|
||
|
</pre>
|
||
|
<p>다음으로 CSS를 작성합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-css" translate="no">#c {
|
||
|
position: fixed;
|
||
|
left: 0;
|
||
|
top: 0;
|
||
|
width: 100%;
|
||
|
height: 100%;
|
||
|
display: block;
|
||
|
z-index: -1;
|
||
|
}
|
||
|
.diagram {
|
||
|
display: inline-block;
|
||
|
width: 5em;
|
||
|
height: 3em;
|
||
|
border: 1px solid black;
|
||
|
}
|
||
|
.left {
|
||
|
float: left;
|
||
|
margin-right: .25em;
|
||
|
}
|
||
|
.right {
|
||
|
float: right;
|
||
|
margin-left: .25em;
|
||
|
}
|
||
|
</pre>
|
||
|
<p>캔버스가 화면 전체를 채우도록 하고 <code class="notranslate" translate="no">z-index</code>를 -1로 설정해 다른 요소 뒤로 가도록 했습니다.
|
||
|
가상 요소에 컨텐츠가 없어 크기가 0이니 별도의 width와 height도 지정해줬습니다.</p>
|
||
|
<p>이제 각각의 카메라와 조명이 있는 장면 2개를 만듭니다. 하나에는 정육면체, 다른 하나에는
|
||
|
다이아몬드 모양을 넣을 겁니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function makeScene(elem) {
|
||
|
const scene = new THREE.Scene();
|
||
|
|
||
|
const fov = 45;
|
||
|
const aspect = 2; // 캔버스 기본값
|
||
|
const near = 0.1;
|
||
|
const far = 5;
|
||
|
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
|
||
|
camera.position.z = 2;
|
||
|
camera.position.set(0, 1, 2);
|
||
|
camera.lookAt(0, 0, 0);
|
||
|
|
||
|
{
|
||
|
const color = 0xFFFFFF;
|
||
|
const intensity = 1;
|
||
|
const light = new THREE.DirectionalLight(color, intensity);
|
||
|
light.position.set(-1, 2, 4);
|
||
|
scene.add(light);
|
||
|
}
|
||
|
|
||
|
return { scene, camera, elem };
|
||
|
}
|
||
|
|
||
|
function setupScene1() {
|
||
|
const sceneInfo = makeScene(document.querySelector('#box'));
|
||
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||
|
const material = new THREE.MeshPhongMaterial({color: 'red'});
|
||
|
const mesh = new THREE.Mesh(geometry, material);
|
||
|
sceneInfo.scene.add(mesh);
|
||
|
sceneInfo.mesh = mesh;
|
||
|
return sceneInfo;
|
||
|
}
|
||
|
|
||
|
function setupScene2() {
|
||
|
const sceneInfo = makeScene(document.querySelector('#pyramid'));
|
||
|
const radius = .8;
|
||
|
const widthSegments = 4;
|
||
|
const heightSegments = 2;
|
||
|
const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
|
||
|
const material = new THREE.MeshPhongMaterial({
|
||
|
color: 'blue',
|
||
|
flatShading: true,
|
||
|
});
|
||
|
const mesh = new THREE.Mesh(geometry, material);
|
||
|
sceneInfo.scene.add(mesh);
|
||
|
sceneInfo.mesh = mesh;
|
||
|
return sceneInfo;
|
||
|
}
|
||
|
|
||
|
const sceneInfo1 = setupScene1();
|
||
|
const sceneInfo2 = setupScene2();
|
||
|
</pre>
|
||
|
<p>이제 각 요소가 화면에 보일 때만 장면을 렌더링할 함수를 만듭니다. <a href="/docs/#api/ko/constants/Renderer.setScissorTest"><code class="notranslate" translate="no">Renderer.setScissorTest</code></a>를
|
||
|
호출해 <em>가위(scissor)</em> 테스트를 활성화하면 Three.js가 캔버스의 특정 부분만 렌더링하도록
|
||
|
할 수 있습니다. 그리고 <a href="/docs/#api/ko/constants/Renderer.setScissor"><code class="notranslate" translate="no">Renderer.setScissor</code></a>로 가위를 설정한 뒤 <a href="/docs/#api/ko/constants/Renderer.setViewport"><code class="notranslate" translate="no">Renderer.setViewport</code></a>로
|
||
|
장면의 좌표를 설정합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function renderSceneInfo(sceneInfo) {
|
||
|
const { scene, camera, elem } = sceneInfo;
|
||
|
|
||
|
// 해당 요소의 화면 대비 좌표를 가져옵니다
|
||
|
const { left, right, top, bottom, width, height } =
|
||
|
elem.getBoundingClientRect();
|
||
|
|
||
|
const isOffscreen =
|
||
|
bottom < 0 ||
|
||
|
top > renderer.domElement.clientHeight ||
|
||
|
right < 0 ||
|
||
|
left > renderer.domElement.clientWidth;
|
||
|
|
||
|
if (isOffscreen) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
camera.aspect = width / height;
|
||
|
camera.updateProjectionMatrix();
|
||
|
|
||
|
const positiveYUpBottom = canvasRect.height - bottom;
|
||
|
renderer.setScissor(left, positiveYUpBottom, width, height);
|
||
|
renderer.setViewport(left, positiveYUpBottom, width, height);
|
||
|
|
||
|
renderer.render(scene, camera);
|
||
|
}
|
||
|
</pre>
|
||
|
<p>다음으로 <code class="notranslate" translate="no">render</code> 함수 안에서 먼저 캔버스 전체를 비운 뒤 각 장면을 렌더링합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function render(time) {
|
||
|
time *= 0.001;
|
||
|
|
||
|
resizeRendererToDisplaySize(renderer);
|
||
|
|
||
|
renderer.setScissorTest(false);
|
||
|
renderer.clear(true, true);
|
||
|
renderer.setScissorTest(true);
|
||
|
|
||
|
sceneInfo1.mesh.rotation.y = time * .1;
|
||
|
sceneInfo2.mesh.rotation.y = time * .1;
|
||
|
|
||
|
renderSceneInfo(sceneInfo1);
|
||
|
renderSceneInfo(sceneInfo2);
|
||
|
|
||
|
requestAnimationFrame(render);
|
||
|
}
|
||
|
</pre>
|
||
|
<p>결과를 확인해볼까요?</p>
|
||
|
<p></p><div translate="no" class="threejs_example_container notranslate">
|
||
|
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/multiple-scenes-v1.html"></iframe></div>
|
||
|
<a class="threejs_center" href="/manual/examples/multiple-scenes-v1.html" target="_blank">새 탭에서 보기</a>
|
||
|
</div>
|
||
|
|
||
|
<p></p>
|
||
|
<p>첫 번째 <code class="notranslate" translate="no"><span></code> 요소가 있는 곳에는 빨간 정육면체가, 두 번째 <code class="notranslate" translate="no"><span></code> 요소가 있는 곳에는
|
||
|
파란 다이아몬드가 보일 겁니다.</p>
|
||
|
<h2 id="-">동기화하기</h2>
|
||
|
<p>위 코드는 나쁘지 않지만 작은 문제가 있습니다. 복잡한 장면 등 무슨 이유라도 렌더링하는
|
||
|
데 시간이 오래 걸린다면, 장면의 좌표는 페이지의 다른 컨텐츠에 비해 더디게 내려올 겁니다.</p>
|
||
|
<p>각 가상 요소에 테두리를 넣고</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-css" translate="no">.diagram {
|
||
|
display: inline-block;
|
||
|
width: 5em;
|
||
|
height: 3em;
|
||
|
+ border: 1px solid black;
|
||
|
}
|
||
|
</pre>
|
||
|
<p>각 장면에 배경색도 넣어줍니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">const scene = new THREE.Scene();
|
||
|
+scene.background = new THREE.Color('red');
|
||
|
</pre>
|
||
|
<p>그런 다음 <a href="../examples/multiple-scenes-v2.html" target="_blank">빠르게 스크롤을 위아래로 반복해보면</a>
|
||
|
문제가 보일겁니다. 아래는 스크롤 애니메이션 캡쳐본의 속도를 10배 낮춘 예시입니다.</p>
|
||
|
<div class="threejs_center"><img class="border" src="../resources/images/multi-view-skew.gif"></div>
|
||
|
|
||
|
<p>추가로 처리해줘야 할 것이 있긴 하지만, 캔버스의 CSS를 <code class="notranslate" translate="no">position: fixed</code>에서 <code class="notranslate" translate="no">position: absolute</code>로
|
||
|
바꿔 문제를 해결할 수 있습니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-css" translate="no">#c {
|
||
|
- position: fixed;
|
||
|
+ position: absolute;
|
||
|
</pre>
|
||
|
<p>그리고 페이지 스크롤에 상관 없이 캔버스가 항상 화면의 상단에 위치할 수 있도록 캔버스에
|
||
|
transform 스타일을 지정해줍니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function render(time) {
|
||
|
...
|
||
|
|
||
|
const transform = `translateY(${ window.scrollY }px)`;
|
||
|
renderer.domElement.style.transform = transform;
|
||
|
</pre>
|
||
|
<p>캔버스에 <code class="notranslate" translate="no">position: fixed</code>를 적용하면 캔버스는 스크롤의 영향을 받지 않습니다. <code class="notranslate" translate="no">position: absolute</code>를
|
||
|
적용하면 렌더링하는 데 시간이 걸리더라도 일단 다른 페이지와 같이 스크롤이 되겠죠. 그리고
|
||
|
렌더링하기 전에 캔버스를 다시 움직여 화면 전체에 맞춘 뒤 캔버스를 렌더링하는 겁니다. 이러면
|
||
|
화면의 가장자리에 살짝 렌더링되지 않은 부분이 보일 수는 있어도 나머지 페이지에 있는 요소는
|
||
|
버벅이지 않고 제자리에 있을 겁니다. 아래는 해당 코드를 적용한 화면의 캡쳐본을 아까와 마찬가지로
|
||
|
10배 느리게 만든 것입니다.</p>
|
||
|
<div class="threejs_center"><img class="border" src="../resources/images/multi-view-fixed.gif"></div>
|
||
|
|
||
|
<h2 id="-">확장하기 쉽게 만들기</h2>
|
||
|
<p>여러 장면을 구현했으니 이제 이 예제를 좀 더 확장하기 쉽게 만들어보겠습니다.</p>
|
||
|
<p>먼저 기존처럼 캔버스 전체를 렌더링하는 <code class="notranslate" translate="no">render</code> 함수를 두고, 각 장면에 해당하는 가상 요소,
|
||
|
해당 장면을 렌더링하는 함수로 이루어진 객체의 배열을 만듭니다. <code class="notranslate" translate="no">render</code> 함수에서 가상 요소가
|
||
|
화면에 보이는지 확인한 뒤, 가상 요소가 화면에 보인다면 상응하는 렌더링 함수를 호출합니다. 이러면
|
||
|
확장성은 물론 각 장면의 렌더링 함수를 작성할 때도 전체를 신경쓸 필요가 없죠.</p>
|
||
|
<p>아래는 전체를 담당하는 <code class="notranslate" translate="no">render</code> 함수입니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">const sceneElements = [];
|
||
|
function addScene(elem, fn) {
|
||
|
sceneElements.push({ elem, fn });
|
||
|
}
|
||
|
|
||
|
function render(time) {
|
||
|
time *= 0.001;
|
||
|
|
||
|
resizeRendererToDisplaySize(renderer);
|
||
|
|
||
|
renderer.setScissorTest(false);
|
||
|
renderer.setClearColor(clearColor, 0);
|
||
|
renderer.clear(true, true);
|
||
|
renderer.setScissorTest(true);
|
||
|
|
||
|
const transform = `translateY(${ window.scrollY }px)`;
|
||
|
renderer.domElement.style.transform = transform;
|
||
|
|
||
|
for (const { elem, fn } of sceneElements) {
|
||
|
// 해당 요소의 화면 대비 좌표를 가져옵니다
|
||
|
const rect = elem.getBoundingClientRect();
|
||
|
const {left, right, top, bottom, width, height} = rect;
|
||
|
|
||
|
const isOffscreen =
|
||
|
bottom < 0 ||
|
||
|
top > renderer.domElement.clientHeight ||
|
||
|
right < 0 ||
|
||
|
left > renderer.domElement.clientWidth;
|
||
|
|
||
|
if (!isOffscreen) {
|
||
|
const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
|
||
|
renderer.setScissor(left, positiveYUpBottom, width, height);
|
||
|
renderer.setViewport(left, positiveYUpBottom, width, height);
|
||
|
|
||
|
fn(time, rect);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
requestAnimationFrame(render);
|
||
|
}
|
||
|
</pre>
|
||
|
<p><code class="notranslate" translate="no">render</code> 함수는 <code class="notranslate" translate="no">elem</code>과 <code class="notranslate" translate="no">fn</code> 속성의 객체로 이루어진 <code class="notranslate" translate="no">sceneElements</code> 배열을 순회합니다.</p>
|
||
|
<p>그리고 각 요소가 화면에 보이는지 확인하고, 화면에 보인다면 <code class="notranslate" translate="no">fn</code>에 해당 장면이 들어가야할
|
||
|
사각 좌표와 현재 시간값을 넘겨주어 호출합니다.</p>
|
||
|
<p>이제 각 장면을 만들고 상응하는 요소와 렌더링 함수를 추가합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">{
|
||
|
const elem = document.querySelector('#box');
|
||
|
const { scene, camera } = makeScene();
|
||
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||
|
const material = new THREE.MeshPhongMaterial({ color: 'red' });
|
||
|
const mesh = new THREE.Mesh(geometry, material);
|
||
|
scene.add(mesh);
|
||
|
addScene(elem, (time, rect) => {
|
||
|
camera.aspect = rect.width / rect.height;
|
||
|
camera.updateProjectionMatrix();
|
||
|
mesh.rotation.y = time * .1;
|
||
|
renderer.render(scene, camera);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
{
|
||
|
const elem = document.querySelector('#pyramid');
|
||
|
const { scene, camera } = makeScene();
|
||
|
const radius = .8;
|
||
|
const widthSegments = 4;
|
||
|
const heightSegments = 2;
|
||
|
const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
|
||
|
const material = new THREE.MeshPhongMaterial({
|
||
|
color: 'blue',
|
||
|
flatShading: true,
|
||
|
});
|
||
|
const mesh = new THREE.Mesh(geometry, material);
|
||
|
scene.add(mesh);
|
||
|
addScene(elem, (time, rect) => {
|
||
|
camera.aspect = rect.width / rect.height;
|
||
|
camera.updateProjectionMatrix();
|
||
|
mesh.rotation.y = time * .1;
|
||
|
renderer.render(scene, camera);
|
||
|
});
|
||
|
}
|
||
|
</pre>
|
||
|
<p><code class="notranslate" translate="no">sceneInfo1</code>, <code class="notranslate" translate="no">sceneInfo2</code>는 더 이상 필요 없으니 제거합니다. 대신 각 mesh의 회전은 해당
|
||
|
장면에서 처리해야 합니다.</p>
|
||
|
<p></p><div translate="no" class="threejs_example_container notranslate">
|
||
|
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/multiple-scenes-generic.html"></iframe></div>
|
||
|
<a class="threejs_center" href="/manual/examples/multiple-scenes-generic.html" target="_blank">새 탭에서 보기</a>
|
||
|
</div>
|
||
|
|
||
|
<p></p>
|
||
|
<h2 id="html-dataset-">HTML Dataset 사용하기</h2>
|
||
|
<p>HTML의 <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset">dataset</a>을
|
||
|
이용하면 좀 더 확장하기 쉬운 환경을 만들 수 있습니다. <code class="notranslate" translate="no">id="..."</code> 대신 <code class="notranslate" translate="no">data-diagram="..."</code>을
|
||
|
이용해 데이터를 직접 HTML 요소에 지정하는 거죠.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-html" translate="no"><canvas id="c"></canvas>
|
||
|
<p>
|
||
|
- <span id="box" class="diagram left"></span>
|
||
|
+ <span data-diagram="box" class="left"></span>
|
||
|
I love boxes. Presents come in boxes.
|
||
|
When I find a new box I'm always excited to find out what's inside.
|
||
|
</p>
|
||
|
<p>
|
||
|
- <span id="pyramid" class="diagram left"></span>
|
||
|
+ <span data-diagram="pyramid" class="right"></span>
|
||
|
When I was a kid I dreamed of going on an expedition inside a pyramid
|
||
|
and finding a undiscovered tomb full of mummies and treasure.
|
||
|
</p>
|
||
|
</pre>
|
||
|
<p>요소의 id를 제거했으니 CSS 셀렉터도 다음처럼 바꾸어야 합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-css" translate="no">-.diagram
|
||
|
+*[data-diagram] {
|
||
|
display: inline-block;
|
||
|
width: 5em;
|
||
|
height: 3em;
|
||
|
}
|
||
|
</pre>
|
||
|
<p>또한 각 장면을 만드는 코드를 <em>scene initialization functions</em>라는 맵으로 만듭니다.
|
||
|
이 맵은 키값에 대응하는 <em>장면 렌더링 함수</em>를 반환할 겁니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">const sceneInitFunctionsByName = {
|
||
|
'box': () => {
|
||
|
const { scene, camera } = makeScene();
|
||
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||
|
const material = new THREE.MeshPhongMaterial({color: 'red'});
|
||
|
const mesh = new THREE.Mesh(geometry, material);
|
||
|
scene.add(mesh);
|
||
|
return (time, rect) => {
|
||
|
mesh.rotation.y = time * .1;
|
||
|
camera.aspect = rect.width / rect.height;
|
||
|
camera.updateProjectionMatrix();
|
||
|
renderer.render(scene, camera);
|
||
|
};
|
||
|
},
|
||
|
'pyramid': () => {
|
||
|
const { scene, camera } = makeScene();
|
||
|
const radius = .8;
|
||
|
const widthSegments = 4;
|
||
|
const heightSegments = 2;
|
||
|
const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
|
||
|
const material = new THREE.MeshPhongMaterial({
|
||
|
color: 'blue',
|
||
|
flatShading: true,
|
||
|
});
|
||
|
const mesh = new THREE.Mesh(geometry, material);
|
||
|
scene.add(mesh);
|
||
|
return (time, rect) => {
|
||
|
mesh.rotation.y = time * .1;
|
||
|
camera.aspect = rect.width / rect.height;
|
||
|
camera.updateProjectionMatrix();
|
||
|
renderer.render(scene, camera);
|
||
|
};
|
||
|
},
|
||
|
};
|
||
|
</pre>
|
||
|
<p>그리고 <code class="notranslate" translate="no">querySelectorAll</code>로 가상 요소를 전부 불러와 해당 요소에 상응하는 렌더링 함수를
|
||
|
실행합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">document.querySelectorAll('[data-diagram]').forEach((elem) => {
|
||
|
const sceneName = elem.dataset.diagram;
|
||
|
const sceneInitFunction = sceneInitFunctionsByName[sceneName];
|
||
|
const sceneRenderFunction = sceneInitFunction(elem);
|
||
|
addScene(elem, sceneRenderFunction);
|
||
|
});
|
||
|
</pre>
|
||
|
<p>이제 코드를 확장하기가 한결 편해졌습니다.</p>
|
||
|
<p></p>
|
||
|
<h2 id="-">각 요소에 액션 추가하기</h2>
|
||
|
<p>사용자 액션, 예를 들어 <code class="notranslate" translate="no">TrackballControls</code>를 추가하는 건 아주 간단합니다. 먼저 스크립트를
|
||
|
불러옵니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">import { TrackballControls } from 'three/addons/controls/TrackballControls.js';
|
||
|
</pre>
|
||
|
<p>그리고 각 장면에 대응하는 요소에 <code class="notranslate" translate="no">TrackballControls</code>를 추가합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">-function makeScene() {
|
||
|
+function makeScene(elem) {
|
||
|
const scene = new THREE.Scene();
|
||
|
|
||
|
const fov = 45;
|
||
|
const aspect = 2; // 캔버스 기본값
|
||
|
const near = 0.1;
|
||
|
const far = 5;
|
||
|
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
|
||
|
camera.position.set(0, 1, 2);
|
||
|
camera.lookAt(0, 0, 0);
|
||
|
+ scene.add(camera);
|
||
|
|
||
|
+ const controls = new TrackballControls(camera, elem);
|
||
|
+ controls.noZoom = true;
|
||
|
+ controls.noPan = true;
|
||
|
|
||
|
{
|
||
|
const color = 0xFFFFFF;
|
||
|
const intensity = 1;
|
||
|
const light = new THREE.DirectionalLight(color, intensity);
|
||
|
light.position.set(-1, 2, 4);
|
||
|
- scene.add(light);
|
||
|
+ camera.add(light);
|
||
|
}
|
||
|
|
||
|
- return { scene, camera };
|
||
|
+ return { scene, camera, controls };
|
||
|
}
|
||
|
</pre>
|
||
|
<p>위 코드에서는 카메라를 장면에 추가하고, 카메라에 조명을 추가했습니다. 이러면 조명이 카메라를
|
||
|
따라다니겠죠. <code class="notranslate" translate="no">TrackballControls</code>는 카메라를 조정하기 때문에 이렇게 해야 빛이 계속 우리가
|
||
|
바라보는 방향에서 나갑니다.</p>
|
||
|
<p>또한 컨트롤을 렌더링 함수에서 업데이트해줘야 합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">const sceneInitFunctionsByName = {
|
||
|
- 'box': () => {
|
||
|
- const {scene, camera} = makeScene();
|
||
|
+ 'box': (elem) => {
|
||
|
+ const { scene, camera, controls } = makeScene(elem);
|
||
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||
|
const material = new THREE.MeshPhongMaterial({color: 'red'});
|
||
|
const mesh = new THREE.Mesh(geometry, material);
|
||
|
scene.add(mesh);
|
||
|
return (time, rect) => {
|
||
|
mesh.rotation.y = time * .1;
|
||
|
camera.aspect = rect.width / rect.height;
|
||
|
camera.updateProjectionMatrix();
|
||
|
+ controls.handleResize();
|
||
|
+ controls.update();
|
||
|
renderer.render(scene, camera);
|
||
|
};
|
||
|
},
|
||
|
- 'pyramid': () => {
|
||
|
- const { scene, camera } = makeScene();
|
||
|
+ 'pyramid': (elem) => {
|
||
|
+ const { scene, camera, controls } = makeScene(elem);
|
||
|
const radius = .8;
|
||
|
const widthSegments = 4;
|
||
|
const heightSegments = 2;
|
||
|
const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
|
||
|
const material = new THREE.MeshPhongMaterial({
|
||
|
color: 'blue',
|
||
|
flatShading: true,
|
||
|
});
|
||
|
const mesh = new THREE.Mesh(geometry, material);
|
||
|
scene.add(mesh);
|
||
|
return (time, rect) => {
|
||
|
mesh.rotation.y = time * .1;
|
||
|
camera.aspect = rect.width / rect.height;
|
||
|
camera.updateProjectionMatrix();
|
||
|
+ controls.handleResize();
|
||
|
+ controls.update();
|
||
|
renderer.render(scene, camera);
|
||
|
};
|
||
|
},
|
||
|
};
|
||
|
</pre>
|
||
|
<p>이제 각 물체를 자유롭게 회전시킬 수 있습니다.</p>
|
||
|
<p></p><div translate="no" class="threejs_example_container notranslate">
|
||
|
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/multiple-scenes-controls.html"></iframe></div>
|
||
|
<a class="threejs_center" href="/manual/examples/multiple-scenes-controls.html" target="_blank">새 탭에서 보기</a>
|
||
|
</div>
|
||
|
|
||
|
<p></p>
|
||
|
<p>이 기법은 이 사이트 전체에 사용한 기법입니다. <a href="primitives.html">원시 모델에 관한 글</a>과
|
||
|
<a href="materials.html">재질에 관한 글</a>에서 다양한 예시를 보여주기 위해 사용했죠.</p>
|
||
|
<p>다른 방법으로는 화면 밖의 캔버스에서 장면을 렌더링해 각 요소에 2D 캔버스 형태로 넘겨주는
|
||
|
방법이 있습니다. 이 방법의 장점은 각 영역을 어떻게 분리할지 고민하지 않아도 된다는 것이죠.
|
||
|
위에서 살펴본 방법은 캔버스를 화면 전체의 배경으로 써야 하지만, 이 방법은 일반 HTML 형태로
|
||
|
사용할 수 있습니다.</p>
|
||
|
<p>하지만 이 방법은 각 영역을 복사하는 것이기에 성능이 더 느립니다. 얼마나 느릴지는 브라우저와
|
||
|
GPU 성능에 따라 다르죠.</p>
|
||
|
<p>바꿔야 하는 건 생각보다 많지 않습니다.</p>
|
||
|
<p>먼저 배경에서 캔버스 요소를 제거합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-html" translate="no"><body>
|
||
|
- <canvas id="c"></canvas>
|
||
|
...
|
||
|
</body>
|
||
|
</pre>
|
||
|
<p>CSS도 바꿔줍니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-css" translate="no">-#c {
|
||
|
- position: absolute;
|
||
|
- left: 0;
|
||
|
- top: 0;
|
||
|
- width: 100%;
|
||
|
- height: 100%;
|
||
|
- display: block;
|
||
|
- z-index: -1;
|
||
|
-}
|
||
|
canvas {
|
||
|
width: 100%;
|
||
|
height: 100%;
|
||
|
display: block;
|
||
|
}
|
||
|
*[data-diagram] {
|
||
|
display: inline-block;
|
||
|
width: 5em;
|
||
|
height: 3em;
|
||
|
}
|
||
|
</pre>
|
||
|
<p>캔버스 요소가 부모에 꽉 차도록 변경했습니다.</p>
|
||
|
<p>이제 자바스크립트를 변경해봅시다. 먼저 캔버스를 참조할 필요가 없으니 대신 캔버스 요소를
|
||
|
새로 만듭니다. 또한 가위 테스트를 처음에 활성화합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function main() {
|
||
|
- const canvas = document.querySelector('#c');
|
||
|
+ const canvas = document.createElement('canvas');
|
||
|
const renderer = new THREE.WebGLRenderer({canvas, alpha: true});
|
||
|
+ renderer.setScissorTest(true);
|
||
|
|
||
|
...
|
||
|
</pre>
|
||
|
<p>다음으로 각 장면에 2D 렌더링 컨텍스트를 생성하고 장면에 대응하는 요소에 캔버스를 추가합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">const sceneElements = [];
|
||
|
function addScene(elem, fn) {
|
||
|
+ const ctx = document.createElement('canvas').getContext('2d');
|
||
|
+ elem.appendChild(ctx.canvas);
|
||
|
- sceneElements.push({ elem, fn });
|
||
|
+ sceneElements.push({ elem, ctx, fn });
|
||
|
}
|
||
|
</pre>
|
||
|
<p>만약 렌더링 시 렌더링용 캔버스의 크기가 장면의 크기보다 작을 경우, 렌더링용 캔버스의 크기를
|
||
|
키웁니다. 또한 2D 캔버스의 크기가 부모 요소와 다르다면 2D 캔버스의 크기를 조정합니다. 마지막으로
|
||
|
가위와 화면을 설정하고, 해당 장면을 렌더링한 뒤, 요소의 캔버스로 렌더링 결과물을 복사합니다.</p>
|
||
|
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">function render(time) {
|
||
|
time *= 0.001;
|
||
|
|
||
|
- resizeRendererToDisplaySize(renderer);
|
||
|
-
|
||
|
- renderer.setScissorTest(false);
|
||
|
- renderer.setClearColor(clearColor, 0);
|
||
|
- renderer.clear(true, true);
|
||
|
- renderer.setScissorTest(true);
|
||
|
-
|
||
|
- const transform = `translateY(${ window.scrollY }px)`;
|
||
|
- renderer.domElement.style.transform = transform;
|
||
|
|
||
|
- for (const { elem, fn } of sceneElements) {
|
||
|
+ for (const { elem, fn, ctx } of sceneElements) {
|
||
|
// 해당 요소의 화면 대비 좌표를 가져옵니다
|
||
|
const rect = elem.getBoundingClientRect();
|
||
|
const { left, right, top, bottom, width, height } = rect;
|
||
|
+ const rendererCanvas = renderer.domElement;
|
||
|
|
||
|
const isOffscreen =
|
||
|
bottom < 0 ||
|
||
|
- top > renderer.domElement.clientHeight ||
|
||
|
+ top > window.innerHeight ||
|
||
|
right < 0 ||
|
||
|
- left > renderer.domElement.clientWidth;
|
||
|
+ left > window.innerWidth;
|
||
|
|
||
|
if (!isOffscreen) {
|
||
|
- const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
|
||
|
- renderer.setScissor(left, positiveYUpBottom, width, height);
|
||
|
- renderer.setViewport(left, positiveYUpBottom, width, height);
|
||
|
|
||
|
+ // 렌더링용 캔버스 크기 조정
|
||
|
+ if (rendererCanvas.width < width || rendererCanvas.height < height) {
|
||
|
+ renderer.setSize(width, height, false);
|
||
|
+ }
|
||
|
+
|
||
|
+ // 2D 캔버스의 크기가 요소의 크기와 같도록 조정
|
||
|
+ if (ctx.canvas.width !== width || ctx.canvas.height !== height) {
|
||
|
+ ctx.canvas.width = width;
|
||
|
+ ctx.canvas.height = height;
|
||
|
+ }
|
||
|
+
|
||
|
+ renderer.setScissor(0, 0, width, height);
|
||
|
+ renderer.setViewport(0, 0, width, height);
|
||
|
|
||
|
fn(time, rect);
|
||
|
|
||
|
+ // 렌더링된 장면을 2D 캔버스에 복사
|
||
|
+ ctx.globalCompositeOperation = 'copy';
|
||
|
+ ctx.drawImage(
|
||
|
+ rendererCanvas,
|
||
|
+ 0, rendererCanvas.height - height, width, height, // 원본 사각 좌표
|
||
|
+ 0, 0, width, height); // 결과물 사각 좌표
|
||
|
}
|
||
|
}
|
||
|
|
||
|
requestAnimationFrame(render);
|
||
|
}
|
||
|
</pre>
|
||
|
<p>결과물은 위와 다르지 않습니다.</p>
|
||
|
<p></p><div translate="no" class="threejs_example_container notranslate">
|
||
|
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/multiple-scenes-copy-canvas.html"></iframe></div>
|
||
|
<a class="threejs_center" href="/manual/examples/multiple-scenes-copy-canvas.html" target="_blank">새 탭에서 보기</a>
|
||
|
</div>
|
||
|
|
||
|
<p></p>
|
||
|
<p>이 기법의 다른 장점은 <a href="https://developer.mozilla.org/ko/docs/Web/API/OffscreenCanvas"><code class="notranslate" translate="no">OffscreenCanvas</code></a>
|
||
|
웹 워커를 이용해 이 기능을 별도 스레드에서 구현할 수 있다는 겁니다. 하지만 아쉽게도
|
||
|
2020년 7월을 기준으로 <code class="notranslate" translate="no">OffscreenCanvas</code>는 아직 크로미움 기반 브라우저에서만 지원합니다.</p>
|
||
|
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<script src="/manual/resources/prettify.js"></script>
|
||
|
<script src="/manual/resources/lesson.js"></script>
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
</body></html>
|