three.js에서의 행렬 변환(Matrix Transformations)

이 글은 three.js의 공식 사이트에서 제공되는 글을 한글로 번역된 내용으로 내용을 좀더 이해하기 쉽게 보강하여 재 작성하였습니다..

three.js는 행렬을 사용해 3차원 변환(이동, 회전, 크기)에 대한 정보를 인코딩합니다. 여기서 인코딩이라는 의미가 수학적이지 못해 혼란스러운데 인코딩이라고 한 이유는 이동과 회전 그리고 크기에 대한 값을 각각의 속성으로 개발자가 지정하면 4×4 크기의 행렬 요소의 값으로 계산되어져 지정된다는 의미입니다. Object3D 클래스의 모든 인스턴세는 matrix 속성을 가지고 있으며 이 속성에는 객체의 위치, 회전, 크기에 대한 값이 저장되어 있습니다. 이 글은 객체의 변환이 어떻게 업데이트 되는지를 설명합니다.

편의 속성과 matrixAutoUpdate

객체의 변환을 업데이트하는 2가지 방식이 존재합니다.

첫번째는, 객체의 position, quaternion, scale 속성을 변경하면 three.js는 이 3개의 속성들을 이용해 객체의 matrix 속성을 다시 계산합니다.

object.position.copy( start_position );
object.quaternion.copy( quaternion );

기본적으로 matrixAutoUpdate 속성이 true 값으로 지정되어 있는데, 이 경우 앞서 말한 것처럼 행렬 속성은 자동으로 다시 계산되어 집니다. 만약 객체가 움직이지 않고 고정된 상태이거나 matrix에 대한 재계산을 수동으로 실행하고자 한다면 이 matrixAutoUpdate를 false로 지정해서 더 나은 성능을 도모할 수 있습니다.

object.matrixAutoUpdate = false;

위의 코드처럼 matrixAutoUpdate를 false로 지정했다면, matrix를 재계산하도록 하기 위해 다음 코드를 실행하면 됩니다.

object.updateMatrix();

두번째는, 객체의 matrix 속성을 직접 변경할 때입니다. Matrix4 클래스는 행렬의 갑슬 변경할 수 있는 다양한 메서드를 제공합니다.

object.matrix.setRotationFromQuaternion( quaternion );
object.matrix.setPosition( start_position );
object.matrixAutoUpdate = false;

이처럼 matrix 속성의 내부 값을 직접 변경한 경우 matrixAutoUpdate 속성을 반드시 false로 지정해야 한다는 점에 주의해야 하며 updateMatrix 매서드를 호출해서는 안됩니다. updateMatrix 매서드를 호출하는 순간 객체의
position, quaternion, scale 속성을 기반으로 matrix 속성이 재계산되기 때문입니다.

객체와 월드 행렬

객체의 matrix 속성은 객체의 변환에 대한 정보를 담고 있는데 이 값들은 객체의 부모에 대한 상대적인 값입니다. 월드 좌표계에 대한 객체의 변환 정보를 얻고자 한다면 반드시 객체의 matrixWorld 속성을 사용해야 합니다.

부모 또는 자식 객체의 변환이 변경되었을 때, 자식 객체의 matrixWorld 속성은 updateMatrixWorld 매서드를 호출함으로써 업데이트되게 할 수 있습니다.

회전과 쿼터니안(Quaternion)

three.js는 3차원 회전을 2가지 방식으로 제공합니다. 즉, 오일러 각(Euler angles)과 쿼터니안(Quaternions)입니다. 이 2개는 서로 변환될 수 있는 매서드를 제공합니다. 오일러 각은 짐벌락(Gimbal Lock)이라 문제를 가지고 있습니다. 짐벌락은 어떤 회전 상태에 도달하면 3개의 축에 대한 회전 자유도가 소실되는 문제로 서로 다른 축으로 회전을 아무리 해도 오직 한개의 축에 대해서만 회전되는 문제입니다. 이러한 이유로 객체의 회전 정보는 항상 쿼터니안 속성에 저장되어 있습니다.

three.js이 과거 버전에서는 useQuaternion이라는 속성을 제공하여 false로 지정하면 오일러 각을 사용해 matrix 속성을 재계산합니다. 이런 방식은 폐기 되었으며 만약 이런 방식의 사용이 필요하다면 setRotationFromEuler 매서드를 사용하여 객체의 quaternion 속성을 업데이트 하도록 해야 합니다.

[오프라인강좌 소개] three.js와 blender를 이용한 3D 인터렉티브 웹 개발

안녕하세요, GIS Developer 김형준입니다. 오는 10월 24일에서 26일 판교에서 3D 그래픽 웹 개발을 위한 three.js 라이브러리와 3차원 모델링 제작툴인 Blender를 활용한 3차원 인터렉티브 웹 개발에 대한 오프라인 강좌를 진행합니다. 소개 영상은 아래와 같습니다.

이번 강좌는 한국메타버스산업협회에서 주관하는 강좌로 교육비는 무료이며 수강신청은 아래 URL을 통해 가능합니다.

https://www.metaverse-campus.kr/lecture/viewAll.do?pageIndex=1&menu_idx=50&lecIdx=17&proIdx=147

참여 인원수에 제한이 있으므로 빠른 신청 부탁드리겠습니다. 감사합니다.

three.js로 웹에서 멋진 3D 장면 연출하기

three.js를 이용하여 웹에서.. 몽환적인 장면을 만들어 보는 코드를 작성해 보았습니다. 결과는 다음 동영상과 같습니다.

복잡한 Shader를 사용하지 않았습니다. three.js의 기본적인 API만을 사용했습니다.

또 다른 한가지 예입니다. 피닉스 한마리가 날아오르는 장면을 연출한 것인데요. 처음엔 생기가 없지만 조금씩 높이 날아 오를수록 몸이 빛나기 시작합니다.

조만간 제 Youtube 채널(GIS DEVELOPER)에 위 2가지에 예제를 three.js로 어떻게 만드는지 그 내용을 업로드할 예정입니다.

THREE.JS 퀵 레퍼런스 코드

Three.js를 이용한 개발 시 개인적으로 빠르게 참조하기 위해 작성한 글입니다.

그림자 적용에 대한 코드

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.VSMShadowMap;

const shadowLight = new THREE.DirectionalLight(0xffe79d, 0.7);
shadowLight.position.set(150, 220, 100);
shadowLight.target.position.set(0,0,0);
shadowLight.castShadow = true;
shadowLight.shadow.mapSize.width = 1024*10;
shadowLight.shadow.mapSize.height = 1024*10;
shadowLight.shadow.camera.top = shadowLight.shadow.camera.right = 1000;
shadowLight.shadow.camera.bottom = shadowLight.shadow.camera.left = -1000;
shadowLight.shadow.camera.far = 800;
shadowLight.shadow.radius = 5;
shadowLight.shadow.blurSamples = 5;
shadowLight.shadow.bias = -0.0002;

const cameraHelper = new THREE.CameraHelper(shadowLight.shadow.camera);
this._scene.add(cameraHelper);

island.receiveShadow = true;
island.castShadow = true;

지오메트리의 좌표 수정

const sphereGeom = new THREE.SphereGeometry(6 + Math.floor(Math.random() * 12), 8, 8);
const sphereGeomPosition = sphereGeom.attributes.position;
for (var i = 0; i < sphereGeomPosition.count; i++) {
    sphereGeomPosition.setY(i, sphereGeomPosition.getY(i) + Math.random() * 4 - 2);
    sphereGeomPosition.setX(i, sphereGeomPosition.getX(i) + Math.random() * 3 - 1.5);
    sphereGeomPosition.setZ(i, sphereGeomPosition.getZ(i) + Math.random() * 3 - 1.5);
}

sphereGeom.computeVertexNormals();
sphereGeom.attributes.position.needsUpdate = true;

지오메트리에 사용자 정의 데이터 주입

// 주입
const waves = [];
const waterGeoPositions = waterGeo.attributes.position;
for (let i = 0; i < waterGeoPositions.count; i++) {
    waves[i] = Math.random() * 100;
}
waterGeo.setAttribute("wave", new THREE.Float32BufferAttribute(waves, 1));

// 읽기
const waves = sea.geometry.attributes.wave;

for(let i=0; i<positions.count; i++) {
    const v = waves.getX(i);
}

안개 설정 코드

scene.fog = new THREE.Fog("rgba(54,219,214,1)", 1000, 1400);

OrbitControls 관련 코드

const controls = new OrbitControls(this._camera, this._divContainer);
controls.minPolarAngle = -Math.PI / 2;
controls.maxPolarAngle = Math.PI / 2 + 0.1;
controls.enableZoom = true;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.2;

this._controls = controls;

this._controls.update();

Object3D의 MBR 얻기

const board = this._scene.getObjectByName("Board");
const box = new THREE.Box3().setFromObject(board);
console.log(box);

Mesh의 월드좌표에 대한 position 얻기

mesh.updateMatrixWorld();

const worldPos = new THREE.Vector3();
worldPos.setFromMatrixPosition(worldPos.matrixWorld);

Faked Shadow

그림자를 위한 매시에 대한 재질 속성 지정이 핵심. 참고로 shadow에 대한 이미지는 투명 이미지가 아님. 즉, 배경색이 하얀색인 이미지임.

const shadow = new THREE.TextureLoader().load( 'models/gltf/ferrari_ao.png' );

const mesh = new THREE.Mesh(
    new THREE.PlaneGeometry( 0.655 * 4, 1.3 * 4 ),
    new THREE.MeshBasicMaterial( {
        map: shadow, 
        blending: THREE.MultiplyBlending, 
        toneMapped: false, 
        transparent: true
    } )
);
mesh.rotation.x = - Math.PI / 2;
mesh.renderOrder = 2;
carModel.add( mesh );

텍스쳐 이미지 품질 올리기

샘플링 횟수를 올리는 것으로 속도는 느려질 수 있으나 품질은 향상됨

texture.anisotropy = renderer.capabilities.getMaxAnisotropy();

async 리소스 로딩

async function init() {
    const rgbeLoader = new RGBELoader().setPath('textures/equirectangular/');
    const gltfLoader = new GLTFLoader().setPath('models/gltf/DamagedHelmet/glTF/');

    const [texture, gltf] = await Promise.all([
        rgbeLoader.loadAsync( 'venice_sunset_1k.hdr' ),
        gltfLoader.loadAsync( 'DamagedHelmet.gltf' ),
    ]);
}

init().catch(function(err) {
    console.error(err);
});

텍스쳐를 Canvas로 후다닥 만들기

const canvas = document.createElement( 'canvas' );
canvas.width = 1;
canvas.height = 32;

const context = canvas.getContext( '2d' );
const gradient = context.createLinearGradient( 0, 0, 0, 32 );
gradient.addColorStop( 0.0, '#ff0000' );
gradient.addColorStop( 0.5, '#00ff00' );
gradient.addColorStop( 1.0, '#0000ff' );
context.fillStyle = gradient;
context.fillRect( 0, 0, 1, 32 );

const sky = new THREE.Mesh(
	new THREE.SphereGeometry( 10 ),
	new THREE.MeshBasicMaterial( { map: new THREE.CanvasTexture( canvas ), side: THREE.BackSide } )
);
scene.add( sky );

GLTF 파일 로딩

import { GLTFLoader } from "../examples/jsm/loaders/GLTFLoader.js"

const loader = new GLTFLoader();
loader.load("./data/ring.glb", gltf => {
    const object = gltf.scene;
    this._scene.add(object);
});    

InstancedMesh

const mesh = new THREE.InstancedMesh(geometry, material, 10000)

const matrix = new THREE.Matrix4()
const dummy = new THREE.Object3D()
 
for(let i = 0; i < 10000; i++) {
  mesh.getMatrixAt(i, matrix)
  matrix.decompose(dummy.position, dummy.rotation, dummy.scale)
  
  dummy.rotation.x = Math.random()
  dummy.rotation.y = Math.random()
  dummy.rotation.z = Math.random()

  dummy.updateMatrix()

  mesh.setMatrixAt(i, dummy.matrix)
  mesh.setColorAt(i, new THREE.Color(Math.random() * 0xffffff)
}

mesh.instanceMatrix.needsUpdate()