THREE.JS 퀵 레퍼런스 코드
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 측정
let frameCount = 0;
let lastTime = performance.now();
let fps = 60;
function updateFPS() { // 렌더링 함수에서 호출해야 함
frameCount++;
const currentTime = performance.now();
const deltaTime = currentTime - lastTime;
if (deltaTime >= 1000) {
fps = Math.round((frameCount * 1000) / deltaTime);
frameCount = 0;
lastTime = currentTime;
}
}
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);
여러 개의 지오메트리들을 한 개의 지오메트리로 병합하기
const mat3 = new THREE.MeshBasicMaterial();
const pumpkin = await this.loadGLTF('./resources/models/pumpkin.glb');
const geos = [];
// 아래의 2줄에 대한 변환 코드는 updateMatrixWorld 매서드가 실행되면 변환 값이 matrixWorld에 반영됨
pumpkin.children[0].position.set(0, 0, 0);
pumpkin.children[0].scale.setScalar(0.01);
pumpkin.children[0].traverse(child => {
child.updateMatrixWorld();
if(child.isMesh) {
if(child.geometry) {
const attribute = child.geometry.getAttribute('position').clone();
const geometry = new THREE.BufferGeometry();
geometry.setIndex(child.geometry.index);
geometry.setAttribute('position', attribute);
geometry.applyMatrix4(child.matrixWorld);
geos.push(geometry);
}
}
});
const combinedGeometry = BufferGeometryUtils.mergeGeometries(geos);
const combinedMesh = new THREE.Mesh(combinedGeometry, mat3);
마우스 커서 위치에 대한 3차원 공간 좌표(평면 기준) 얻기
const mouse3D = new THREE.Vector3(0, 0, 0);
const raycaster = new THREE.Raycaster();
const intersectionPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
window.addEventListener('mousemove', (event) => {
const mouse = new THREE.Vector2(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
// Plane 수학 모델을 통한 마우스 커서의 Plane 상의 3차원 위치 얻기(mouse3D에 저장)
raycaster.ray.intersectPlane(intersectionPlane, mouse3D);
});
보호된 글: three.js 수풀과 바람 시뮬레이션 코드
기본값을 가지는 2차원 배열을 만드는 효율적인 코드
const x = 5; const y = 7; const cells = [...Array(x)].map(_ => [...Array(y)].map(_ => null)); console.log(cells);

Particle에 대한 Attractor Force 적용
particle 시뮬레이션에서
가장 기본적인 attractor force는 입자 위치와 어트랙터 위치 사이의 거리 벡터를 기반으로 계산된다. 일반적으로 힘의 방향은 (attractorPosition – particlePosition)으로 정의되며, 크기는 거리의 함수로 결정된다. 이때 거리 제곱에 반비례하는 형태(예: 중력 공식)를 사용하면, 가까울수록 강하게 끌리고 멀어질수록 약해지는 자연스러운 움직임을 만들 수 있다.
수식적으로는 다음과 같은 형태가 자주 사용된다.
여기서 dir은 정규화된 방향 벡터, distance는 입자와 어트랙터 사이의 거리, n은 감쇠 차수(보통 1 또는 2), ε는 수치적 불안정을 방지하기 위한 작은 값이다. 이 구조는 물리적으로 그럴듯하면서도 계산 비용이 비교적 낮다는 장점이 있다.
시뮬레이션 관점에서 attractor force는 속도(velocity)에 누적(accumulate)되는 힘으로 처리된다. 즉, 매 프레임마다 어트랙터 힘을 계산해 가속도(acceleration)에 반영하고, 이를 적분하여 속도와 위치를 갱신한다. 이 과정에서 타임스텝(delta time)을 고려하지 않으면 프레임 레이트에 따라 시뮬레이션 결과가 달라질 수 있다.
attractor는 단일 지점일 수도 있고, 여러 개가 동시에 존재할 수도 있다. 다중 어트랙터 환경에서는 각 어트랙터로부터의 힘을 합산하여 최종 힘을 계산하며, 이로 인해 입자가 특정 궤도에 머무르거나 카오스적인 움직임을 보이는 패턴이 생성된다. 이는 파티클 아트, 데이터 시각화, 은하 시뮬레이션 등에서 자주 활용된다.
실무적으로는 attractor force를 그대로 적용하면 입자가 과도하게 가속되어 불안정해지는 경우가 많기 때문에, force clamp(최대 힘 제한), damping(감쇠), 또는 soft radius(일정 거리 이내에서만 작동) 같은 보정 기법을 함께 사용한다. 이러한 제어 장치는 시각적으로 안정적이고 예측 가능한 결과를 만드는 데 필수적이다.
이제 구현 관점에서 설펴보자.
먼저 파티클을 간단히 정의해 보자.
class Particle {
constructor(x, y, z) {
this.position = new Vector3(x, y, z);
this.velocity = new Vector3(0, 0, 0);
this.mass = 1.0;
}
}
각 파티클은 위치(position), 속도(velocity), 질량(mass)을 가진다. 질량은 힘 → 가속도 변환에 사용된다. 이제 Attractor에 대해 정의해 보자. 어트랙터는 위치와 힘의 세기(strength)를 가진다. 물리적으로는 “질량을 가진 중심점”에 해당한다.
class Attractor {
constructor(x, y, z, strength = 10.0) {
this.position = new Vector3(x, y, z);
this.strength = strength;
}
}
파티클에 대한 어트랙터가 미치는 힘의 영향을 계산하는 함수다. 입자 → 어트랙터 방향 벡터를 구하고, 거리 기반 감쇠를 적용한다. 여기서는 거리 제곱에 반비례하는 힘 모델을 사용한다.
function computeAttractorForce(particle, attractor) {
const dir = attractor.position.clone().sub(particle.position);
const distanceSq = Math.max(dir.lengthSq(), 0.0001); // 수치 안정성
dir.normalize();
// F = G / r^2
const forceMagnitude = attractor.strength / distanceSq;
return dir.multiplyScalar(forceMagnitude);
}
이제 시뮬레이션이 가능하다. 매 프레임마다 힘 → 가속도 → 속도 → 위치 순서로 갱신한다. 이 예제에서는 감쇠(damping)를 추가해 발산을 방지한다.
function updateParticle(particle, attractor, deltaTime) {
// 1. attractor force 계산
const force = computeAttractorForce(particle, attractor);
// 2. F = m * a → a = F / m
const acceleration = force.divideScalar(particle.mass);
// 3. 속도 갱신
particle.velocity.add(acceleration.multiplyScalar(deltaTime));
// 4. 감쇠 (damping)
particle.velocity.multiplyScalar(0.98);
// 5. 위치 갱신
particle.position.add(particle.velocity.clone().multiplyScalar(deltaTime));
}
three.js라면 Vector3 클래스를 제공하지만 three.js가 아닌 환경이라면 Vector3에 대한 정의는 다음과 같다.
class Vector3 {
constructor(x = 0, y = 0, z = 0) {
this.x = x;
this.y = y;
this.z = z;
}
clone() {
return new Vector3(this.x, this.y, this.z);
}
add(v) {
this.x += v.x;
this.y += v.y;
this.z += v.z;
return this;
}
sub(v) {
this.x -= v.x;
this.y -= v.y;
this.z -= v.z;
return this;
}
multiplyScalar(s) {
this.x *= s;
this.y *= s;
this.z *= s;
return this;
}
divideScalar(s) {
return this.multiplyScalar(1 / s);
}
lengthSq() {
return this.x * this.x + this.y * this.y + this.z * this.z;
}
normalize() {
const len = Math.sqrt(this.lengthSq());
if (len > 0) this.divideScalar(len);
return this;
}
}
핵심을 좀더 반복해 보면, attractor force는 위치 차 벡터 기반으로 계산한다는 점. 거리 기반 감쇠가 없으면 시뮬레이션이 쉽게 불안정해진다는 점. damping, 최소 거리 제한은 사실상 필수라는 점. 이 구조는 CPU 파티클, GPU 파티클(Compute / GPGPU) 모두에 동일하게 적용된다는 점 등이다.
