본문 바로가기
Three.js/Class techniques

[threejs-journey 2-5] Galaxy Generator

by SL123 2025. 8. 13.

 

개요

Three.js 파티클(THREE.Points)을 응용하여 아름다운 나선 은하를 만드는 방법을 단계별로 문제를 정의해 분할하는 방식으로 안내합니다. dat.gui를 사용해 실시간으로 은하의 형태를 바꾸면서 파티클 시스템의 원리를 깊이 있게 이해해 보겠습니다.

 

 

 

기본설정(함수와 파라미터)

 

먼저 은하를 생성할 generateGalaxy함수를 선언하고, dat.gui로 제어할 설정값들을 parameters 객체에 모아 관리합니다. 이렇게 하면 코드가 깔끔해지고 값을 변경하기 쉬워집니다.

// GUI 컨트롤러
const gui = new dat.GUI();

// 은하의 속성을 관리할 파라미터 객체
const parameters = {};
parameters.count = 100000; // 파티클 개수
parameters.size = 0.01;    // 파티클 크기
parameters.radius = 5;     // 은하의 반지름
parameters.branches = 3;   // 나선팔의 개수
parameters.spin = 1;       // 나선팔의 휘어짐 정도
parameters.randomness = 0.2; // 파티클의 무작위성
parameters.randomnessPower = 3; // 무작위성의 강도 (제곱 값)
parameters.insideColor = '#ff6030';  // 은하 중심 색상
parameters.outsideColor = '#1b3984'; // 은하 외곽 색상

// 이 변수들은 generateGalaxy 함수 외부에서 선언되어야 합니다.
let geometry = null;
let material = null;
let points = null;


// 은하 생성을 시작한다는 로그를 남깁니다.
const generateGalaxy = () => {
    console.log('Generating galaxy...');

	/**
     * 메모리 관리: 이전 객체 제거
     */
    if (points !== null) {
        geometry.dispose(); // Geometry 메모리 해제
        material.dispose(); // Material 메모리 해제
        scene.remove(points); // Scene에서 Points 객체 제거
    }

    const radius = Math.random() * parameters.radius;
        

    /**
     * Geometry
     */
    const geometry = new THREE.BufferGeometry();

    // 파티클의 위치를 담을 배열 (x, y, z 좌표 * 개수)
    const positions = new Float32Array(parameters.count * 3); 

    for(let i = 0; i < parameters.count; i++){
        let i3 = i * 3;
        // -5 ~ +5 사이의 정육면체 공간에 무작위로 배치
        positions[i3]     = (Math.random() - 0.5) * 10; // x
        positions[i3 + 1] = (Math.random() - 0.5) * 10; // y
        positions[i3 + 2] = (Math.random() - 0.5) * 10; // z
    }
    // 지오메트리에 'position' 속성으로 위치 데이터 추가
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

    /**
     * Material
     */
    const material = new THREE.PointsMaterial({
        size: parameters.size,
        sizeAttenuation: true,       // 거리에 따라 크기 작아지게
        depthWrite: false,           // 파티클이 겹칠 때 생기는 문제 방지
        blending: THREE.AdditiveBlending, // 파티클이 겹치면 밝아지는 효과
    });

    /**
     * Points
     */
    const points = new THREE.Points(geometry, material);
    scene.add(points);
}

generateGalaxy();

 

동적 생성을 위한 메모리 관리(중요)

dat.gui로 파라미터를 변경할 때 마다 generateGalaxy 함수가 호출됩니다. 이때 기존에 있던 은하를 지우지 않으면, 수많은 파티클 객체가 중첩되어 렌더링되고 심각한 성능 저하를 유발합니다.

 

따라서 함수가 시작될 때 이전에 생성된 객체가 있는지 확인하고, 있다면 반드시 제거해야 합니다.

  • geometry.dispose() & material.dispose(): GPU에 할당된 지오메트리와 재질 관련 메모리를 명시적으로 해제합니다.
  • scene.remove(points): Three.js의 Scene 그래프에서 객체를 제거합니다. 이 작업을 해야만 자바스크립트 가비지 컬렉터가 해당 객체를 메모리에서 완전히 회수할 수 있습니다.
// generateGalaxy 함수 최상단에 추가
if(points !== null)
{
    geometry.dispose();
    material.dispose();
    scene.remove(points);
}

 

지금까지는 파티클을 정육면체 공간에 무작위로 배치하는 기본 시스템을 만들었습니다. 이제 이 코드를 지우고, 한 단계 더 나아가 수학적 원리를 적용하여 아름다운 은하 형태를 만들어 보겠습니다.  

 

 

 

 

은하 형태 만들기

이제 본격적으로 나선 은하의 형태를 만들어 보겠습니다. 복잡해 보이지만, 간단한 수학 원리를 단계적으로 적용하면 됩니다.

 

 

 

나선팔(branches)의 기본 골격 생성

 

먼저 파티클들을 여러 개의 팔(branch) 모양으로 분산시켜야 합니다. 모듈러(%) 연산자를 사용하면 이 작업을 쉽게 처리할 수 있습니다.

 

i % parameters.branches는 파티클의 인덱스(i)를 팔의 개수로 나눈 나머지를 반환합니다. 예를 들어 branches가 3이라면 결과는 항상 0, 1, 2 중 하나가 됩니다. 이 값을 이용해 각 파티클이 속할 팔을 정하고, 원형으로 배치합니다.

// for 루프 내부
let i3 = i * 3;

const radius = Math.random() * parameters.radius;

// 파티클이 속할 팔의 각도를 계산합니다.
const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2;
// (i % 3) => 0, 1, 2
// / 3       => 0, 0.33, 0.66
// * PI*2    => 원 위에서 각각의 시작 각도가 정해짐

positions[i3]     = Math.cos(branchAngle) * radius; // x 좌표
positions[i3 + 1] = 0;                              // y 좌표
positions[i3 + 2] = Math.sin(branchAngle) * radius; // z 좌표

 

 

 

 

 

소용돌이 효과 추가(Spin)

직선으로 뻗은 팔들을 휘게 만들어 소용돌이 모양을 만듭니다. 핵심 아이디어는 "중심에서 멀수록 더 많이 회전시킨다"입니다. radius값에 spin파라미터를 곱해 spinAngle을 구하고, 이를 branchAngle에 더해주면 간단하게 구혈할 수 있습니다.

// for 루프 내부
const spinAngle = radius * parameters.spin;

// branchAngle에 spinAngle을 더해 최종 각도를 계산합니다.
positions[i3]     = Math.cos(branchAngle + spinAngle) * radius;
positions[i3 + 1] = 0;
positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius;

 

 

 

 

 

자연스러운 무작의성 부여

지금까지 만든 은하는 파티클이 너무 완벽하게 정렬되어 있어 인위적으로 보입니다. Math.random()을 이용해 각 파티클의 위치에 무작위 값을 더해 자연스러움을 추가합시다.

 

하지만 그냥 무작위 값을 더하면 은하 중심부와 외곽부가 동일하게 흩어져 보입니다. 실제 은하는 중심부에 별이 밀집되어 있죠. Math.pow()를 사용해 이 문제를 해결할 수 있습니다.

 

Math.random()은 0과 1사이의 값을 반환합니다. 이 값에 1보다 큰 수로 거듭제곱(Math.pow(Math.random(), 3)) 을 하면 결과값이 0에 더 가까워지는 경향이 생깁니다. 이를 이용해 파티클 대부분이 중심 근처에 약간의 오차만 갖도록 하고, 일부만 멀리 흩어지게 만들 수 있습니다.

// for 루프 내부
const randomX = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * parameters.randomness * radius;
const randomY = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * parameters.randomness * radius;
const randomZ = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * parameters.randomness * radius;

// 최종 위치 계산 시, random 값을 더해줍니다.
positions[i3]     = Math.cos(branchAngle + spinAngle) * radius + randomX;
positions[i3 + 1] = randomY;
positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ;

 

 

 

 

색상 추가하기

은하에 생동감을 불어넣기 위해 색상을 추가합니다. 중심부는 밝고 뜨거은 색(insideColor), 외곽부는 차가운 색(outsideColor)으로 그라데이션 효과를 주겠습니다. 이 때 한번 더 일을 분할해서 생각해보도록 합시다.

  1. colors 배열을 생성하고 geometry에 'color' 속성으로 추가합니다.
  2. 루프 안에서 colorInside를 복제(clone())한 뒤, lerp() 함수를 사용합니다.
  3. lerp(targetColor, alpha)는 현재 색상에서 targetColor까지 alpha(0.0 ~ 1.0) 비율만큼 색을 보간(혼합)합니다.
  4. alpha 값으로 radius / parameters.radius를 사용하면, 중심에 가까울수록(radius가 작을 수록) insideColor에 가깝고, 멀어질수록 outsideColor에 가까워지는 효과를 얻습니다.
// for 루프 내부
// ... 위치 계산 후 ...

// Color
const mixedColor = colorInside.clone();
mixedColor.lerp(colorOutside, radius / parameters.radius); // 거리에 따라 색상 보간

colors[i3]     = mixedColor.r;
colors[i3 + 1] = mixedColor.g;
colors[i3 + 2] = mixedColor.b;

 

그리고 PointsMaterial을 생성할 때 vertexColors: true 옵션을 반드시 추가해야 geometry에 설정된 색상 정보가 적용됩니다.

// Material 생성 부분
material = new THREE.PointsMaterial({
    size: parameters.size,
    sizeAttenuation: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    vertexColors: true // 이 옵션을 꼭 추가해주세요!
});

 

 

 

 

전체 코드 및 GUI 연동

아래는 위에서 설명한 모든 로직이 포함된 generateGalaxy 함수와 dat.gui 설정의 최종 모습입니다.

// 전체 JavaScript 코드 예시

// ... (Scene, Camera, Renderer 등 기본 설정) ...

const gui = new dat.GUI();

const parameters = {
    count: 100000,
    size: 0.01,
    radius: 5,
    branches: 3,
    spin: 1,
    randomness: 0.2,
    randomnessPower: 3,
    insideColor: '#ff6030',
    outsideColor: '#1b3984'
};

let geometry = null;
let material = null;
let points = null;

function generateGalaxy() {
    if (points !== null) {
        geometry.dispose();
        material.dispose();
        scene.remove(points);
    }

    /**
     * Geometry
     */
    geometry = new THREE.BufferGeometry();
    const positions = new Float32Array(parameters.count * 3);
    const colors = new Float32Array(parameters.count * 3);

    const colorInside = new THREE.Color(parameters.insideColor);
    const colorOutside = new THREE.Color(parameters.outsideColor);

    for (let i = 0; i < parameters.count; i++) {
        const i3 = i * 3;

        // Position
        const radius = Math.random() * parameters.radius;
        const spinAngle = radius * parameters.spin;
        const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2;
        
        const randomX = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * parameters.randomness * radius;
        const randomY = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * parameters.randomness * radius;
        const randomZ = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * parameters.randomness * radius;
        
        positions[i3]     = Math.cos(branchAngle + spinAngle) * radius + randomX;
        positions[i3 + 1] = randomY;
        positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ;

        // Color
        const mixedColor = colorInside.clone();
        mixedColor.lerp(colorOutside, radius / parameters.radius);

        colors[i3]     = mixedColor.r;
        colors[i3 + 1] = mixedColor.g;
        colors[i3 + 2] = mixedColor.b;
    }

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

    /**
     * Material
     */
    material = new THREE.PointsMaterial({
        size: parameters.size,
        sizeAttenuation: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        vertexColors: true
    });

    /**
     * Points
     */
    points = new THREE.Points(geometry, material);
    scene.add(points);
}

// GUI 컨트롤 추가
gui.add(parameters, 'count').min(100).max(1000000).step(100).onFinishChange(generateGalaxy);
gui.add(parameters, 'size').min(0.001).max(0.1).step(0.001).onFinishChange(generateGalaxy);
gui.add(parameters, 'radius').min(0.01).max(20).step(0.01).onFinishChange(generateGalaxy);
gui.add(parameters, 'branches').min(2).max(20).step(1).onFinishChange(generateGalaxy);
gui.add(parameters, 'spin').min(-5).max(5).step(0.001).onFinishChange(generateGalaxy);
gui.add(parameters, 'randomness').min(0).max(2).step(0.001).onFinishChange(generateGalaxy);
gui.add(parameters, 'randomnessPower').min(1).max(10).step(0.001).onFinishChange(generateGalaxy);
gui.addColor(parameters, 'insideColor').onFinishChange(generateGalaxy);
gui.addColor(parameters, 'outsideColor').onFinishChange(generateGalaxy);

generateGalaxy();

// ... (tick 함수와 애니메이션 루프)

 

 

 

결론

 

지금까지 Three.js 파티클을 이용해 나선 은하를 만들어보았습니다. 이 과정은 복잡해 보이는 목표를 단순한 문제로 분할하고, 수학적 원리를 단계적으로 적용하여 해결하는 프로그래밍의 방식으로 접근했습니다.

 

기본 파티클 생성부터 메모리 관리, 나선팔 구조, 소용돌이 효과, 자연스러운 분포, 그리고 색상 그라데이션까지, 각 단계를 거치며 코드가 어떻게 진화하는지 확인할 수 있었습니다.

 

가장 중요한 것은 이후에 셰이더를 다루기 위한 초석이기 때문에, dat.gui를 통해 각 파라미터가 시각적으로 어떤 변화를 가져오는 지 직접 체험해보고 원리를 한 번씩 만져보면서 체득해야 합니다. 

'Three.js > Class techniques' 카테고리의 다른 글

[threejs-journey 2-6] Scroll based animation  (1) 2025.08.16
[threejs-journey 2-4] Particles  (0) 2025.08.12
[threejs-journey 2-3] Haunted House  (0) 2025.08.10
[threejs-journey 2-2] shadows  (0) 2025.07.23
[threejs-journey 2-1] Lights  (1) 2025.07.15