Three.js를 이용한 개발 시 개인적으로 빠르게 참조하기 위해 작성한 글입니다.
three.js 기본 프로젝트 생성 (WebGL)
git clone https://github.com/GISDEVCODE/threejs-with-javascript-starter.git .
three.js 기본 프로젝트 생성 (WebGPU)
git clone https://github.com/GISDEVCODE/threejs-webgpu-with-javascript-starter.git .
R3F 기본 프로젝트 생성 (Javascript)
git clone https://github.com/GISDEVCODE/r3f-with-javascript-starter.git .
Shader 기본 프로젝트 생성 (Javascript)
git clone https://github.com/GISDEVCODE/shader-with-threejs-javascript-starter .
그림자 적용에 대한 코드
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(mesh.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()
Image 기반 광원(IBL)
import { RGBELoader } from 'three/examples/jsm/Addons.js'
...
new RGBELoader().setPath("./").load("pine_attic_2k.hdr", (data) => {
data.mapping = THREE.EquirectangularReflectionMapping;
// this.scene.background = data;
// this.scene.backgroundBlurriness = 0.6;
this.scene.environment = data;
})
GLTF / GLB 파일 로딩
import { GLTFLoader, OrbitControls } from "three/addons/Addons.js"
...
const loader = new GLTFLoader();
loader.load(
"fileName.glb",
(gltf) => {
this._scene.add( gltf.scene );
},
(xhr) => { console.log( ( xhr.loaded / xhr.total * 100 ) + "% loaded" ); },
(error) => { console.log( "An error happened" ); }
);
Object3D를 빌보드로 만들기
this._mesh.quaternion.copy(this._camera.quaternion );
// or
this._mesh.rotation.setFromRotationMatrix( this._camera.matrix );
FPS 제한하기
requestAnimationFrame로 렌더링을 수행하면 최대한 많은 프레임을 생성하기 됨. 아래는 원하는 프레임수로 제한하기 위해 다음 코드로 30 프레임 제한입니다.
_elapsedTime = 0;
_fps = 1 / 60
render() {
const delta = this._clock.getDelta();
this.update(delta);
this._elapsedTime += delta;
if (this._elapsedTime >= (this._fps)) {
this._stats.begin();
this._renderer.render(this._scene, this._camera);
this._stats.end();
this._elapsedTime %= this._fps;
}
requestAnimationFrame(this.render.bind(this));
}
화면 좌표를 월드 좌표로 변환하는 함수
// 스크린 좌표 -> 월드 좌표 변환 함수
screenToWorld(screenX, screenY, ndcZ = 0.5) { // ndcZ=0.5로 설정 (카메라 방향)
// 1. 화면 픽셀 좌표를 정규화된 디바이스 좌표(NDC)로 변환
const ndcX = (screenX / this._divContainer.clientWidth) * 2 - 1;
const ndcY = -(screenY / this._divContainer.clientHeight) * 2 + 1;
// 2. NDC를 3D 공간의 Ray로 변환
const vector = new THREE.Vector3(ndcX, ndcY, ndcZ);
vector.unproject(this._camera);
// 3. Ray를 따라 월드 좌표를 계산
return vector;
}
screenToWorldAtZ(screenX, screenY, targetZ = 0) {
// 1. 화면 픽셀 좌표 -> NDC 변환
const ndcX = (screenX / this._divContainer.clientWidth) * 2 - 1;
const ndcY = -(screenY / this._divContainer.clientHeight) * 2 + 1;
// 2. NDC -> Ray 생성
const raycaster = new THREE.Raycaster();
const vector = new THREE.Vector2(ndcX, ndcY);
raycaster.setFromCamera(vector, this._camera);
// 3. Ray와 z=targetZ 평면의 교차점 계산
const direction = raycaster.ray.direction;
const origin = raycaster.ray.origin;
const t = (targetZ - origin.z) / direction.z;
const worldPoint = origin.clone().add(direction.multiplyScalar(t));
return worldPoint;
}
모델을 화면 중심에 표시하기
_zoomFix(model, distanceFactor = 1.5) {
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
const size = box.getSize(new THREE.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z);
const cameraDistance = maxDimension * distanceFactor;
this._camera.position.set(0, 0, cameraDistance);
this._camera.lookAt(this._scene.position);
}
모델의 특정 페이스에 대한 법선 벡터 구하기
_getNormal(mesh, faceIndex) {
const normalAttribute = mesh.geometry.getAttribute("normal");
const index = mesh.geometry.index;
const faceStartIndex = faceIndex * 3;
const vertexIndexA = index.getX(faceStartIndex);
const vertexIndexB = index.getX(faceStartIndex + 1);
const vertexIndexC = index.getX(faceStartIndex + 2);
const normalA = new THREE.Vector3();
const normalB = new THREE.Vector3();
const normalC = new THREE.Vector3();
const faceNormal = new THREE.Vector3();
normalA.fromBufferAttribute(normalAttribute, vertexIndexA);
normalB.fromBufferAttribute(normalAttribute, vertexIndexB);
normalC.fromBufferAttribute(normalAttribute, vertexIndexC);
faceNormal.addVectors(normalA, normalB).add(normalC).divideScalar(3).normalize();
return faceNormal;
}
어떤 위치에서 매시에 가장 가까운 매시 표면의 위치를 얻는 코드
아래 함수의 point 파라메터는 어떤 위치이고 mesh 파라메터가 대상 메시입니다. point에서 가장 가까운 매시 표면의 좌표가 변환됩니다.
findClosestPointOnMesh(/* Vector3 */ point, /* Mesh */ mesh) {
const geometry = mesh.geometry;
const vertices = geometry.attributes.position;
const indices = geometry.index ? geometry.index.array : null;
let closestPoint = new THREE.Vector3();
let minDistance = Infinity;
const localPoint = mesh.worldToLocal(point);
if (indices) {
for (let i = 0; i < indices.length; i += 3) {
const a = new THREE.Vector3().fromBufferAttribute(vertices, indices[i]);
const b = new THREE.Vector3().fromBufferAttribute(vertices, indices[i + 1]);
const c = new THREE.Vector3().fromBufferAttribute(vertices, indices[i + 2]);
const tempPoint = new THREE.Vector3();
const triangle = new THREE.Triangle(a, b, c);
triangle.closestPointToPoint(localPoint, tempPoint);
const distance = localPoint.distanceTo(tempPoint);
if (distance < minDistance) {
minDistance = distance;
closestPoint.copy(tempPoint);
}
}
} else {
for (let i = 0; i < vertices.count; i += 3) {
const a = new THREE.Vector3().fromBufferAttribute(vertices, i);
const b = new THREE.Vector3().fromBufferAttribute(vertices, i + 1);
const c = new THREE.Vector3().fromBufferAttribute(vertices, i + 2);
const tempPoint = new THREE.Vector3();
const triangle = new THREE.Triangle(a, b, c);
triangle.closestPointToPoint(localPoint, tempPoint);
const distance = localPoint.distanceTo(tempPoint);
if (distance < minDistance) {
minDistance = distance;
closestPoint.copy(tempPoint);
}
}
}
return mesh.localToWorld(closestPoint);
}
아래는 위의 함수가 적용된 실행 화면인데, 노란색 포인트 위치가 임의의 위치(point 파라메터)이고 빨간색이 함수의 결과 좌표입니다.
DataTexture를 이용한 Raw 데이터로 텍스쳐 생성
const width = 256;
const height = 256;
const size = width * height;
const data = new Uint8Array(4 * size); // RGBA 데이터
for (let i = 0; i < size; i++) {
const stride = i * 4;
data[stride] = Math.floor(Math.random() * 256); // R
data[stride + 1] = Math.floor(Math.random() * 256); // G
data[stride + 2] = Math.floor(Math.random() * 256); // B
data[stride + 3] = 255; // A
}
const texture = new THREE.DataTexture(data, width, height);
texture.needsUpdate = true;
const material = new THREE.MeshBasicMaterial({ map: texture });
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 4), material);
this._scene.add(mesh);