본문 바로가기
2D&3D/ThreeJS

18 Galaxy Generator

by curious week 2025. 7. 28.

Galaxy Generator

Introduction

이제 particles를 사용할 수 있으니, 멋진 은하(galaxy)를 만들어볼 수 있습니다.
하지만 하나의 은하만 만드는 대신, 매번 다른 은하를 생성할 수 있는 은하 생성기(galaxy generator) 를 만들어보겠습니다.

사용자가 다양한 파라미터를 조절할 수 있도록 하기 위해 lil-gui를 사용할 것입니다.


Setup

시작 프로젝트에는 단순히 장면(scene) 중앙에 큐브 하나만 존재합니다.
기본 작동 여부를 확인하기 위한 구조입니다.


Base Particles

  1. 먼저 큐브를 제거하고 generateGalaxy 함수를 생성합니다.
    이 함수는 기존 은하가 존재하면 제거하고 새로운 은하를 생성합니다.
/**
 * Galaxy
 */
const generateGalaxy = () => {};

generateGalaxy();
  1. 은하의 파라미터를 저장할 객체를 만들고, 이 객체를 generateGalaxy 함수 위에 정의합니다:
const parameters = {};
  1. 기본 geometry를 만들고, particle의 개수를 지정합니다:
const parameters = {};
parameters.count = 1000;

const generateGalaxy = () => {
  /**
   * Geometry 생성
   */
  const geometry = new THREE.BufferGeometry();

  // 각 입자의 좌표(x, y, z)를 저장할 배열
  const positions = new Float32Array(parameters.count * 3);

  for (let i = 0; i < parameters.count; i++) {
    const i3 = i * 3;
    positions[i3] = (Math.random() - 0.5) * 3;
    positions[i3 + 1] = (Math.random() - 0.5) * 3;
    positions[i3 + 2] = (Math.random() - 0.5) * 3;
  }

  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
};
generateGalaxy();
  1. PointsMaterial을 사용해서 material을 생성합니다:
parameters.size = 0.02;

const generateGalaxy = () => {
  // ...

  /**
   * Material 생성
   */
  const material = new THREE.PointsMaterial({
    size: parameters.size,
    sizeAttenuation: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
  });
};
  1. Points 객체를 생성하고 scene에 추가합니다:
const generateGalaxy = () => {
  // ...

  /**
   * Points 생성 및 scene 추가
   */
  const points = new THREE.Points(geometry, material);
  scene.add(points);
};

Tweaks

현재 count, size라는 두 가지 파라미터가 있습니다. 이를 lil-gui에서 조절할 수 있게 추가합니다:

gui.add(parameters, 'count').min(100).max(1000000).step(100);
gui.add(parameters, 'size').min(0.001).max(0.1).step(0.001);

하지만 이렇게만 하면 값을 바꿔도 은하가 갱신되지 않습니다. onFinishChange(generateGalaxy) 이벤트를 등록해야 합니다:

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);

이벤트는 generateGalaxy 함수 정의 이후에 등록해야 정상 작동합니다.


이전 은하 제거하기

기존 은하를 제거하지 않으면 메모리에 계속 쌓여서 컴퓨터가 느려집니다. 아래처럼 처리합니다:

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

const generateGalaxy = () => {
  // 이전 은하 제거
  if (points !== null) {
    geometry.dispose();
    material.dispose();
    scene.remove(points);
  }

  geometry = new THREE.BufferGeometry();
  material = new THREE.PointsMaterial({
    size: parameters.size,
    sizeAttenuation: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
  });
  points = new THREE.Points(geometry, material);
  scene.add(points);
};

Shape

Radius

반지름 설정:

parameters.radius = 5;

gui
  .add(parameters, 'radius')
  .min(0.01)
  .max(20)
  .step(0.01)
  .onFinishChange(generateGalaxy);

입자 하나하나가 이 반지름 값에 따라 거리 0부터 반지름 값 사이에 배치됩니다.

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

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

  positions[i3] = radius;
  positions[i3 + 1] = 0;
  positions[i3 + 2] = 0;
}

Branches

은하의 팔 개수를 조절하는 파라미터:

parameters.branches = 3;

gui
  .add(parameters, 'branches')
  .min(2)
  .max(20)
  .step(1)
  .onFinishChange(generateGalaxy);

브랜치 각도 계산 후 배치:

const branchAngle =
  ((i % parameters.branches) / parameters.branches) * Math.PI * 2;
positions[i3] = Math.cos(branchAngle) * radius;
positions[i3 + 2] = Math.sin(branchAngle) * radius;

Spin

회전을 적용하여 소용돌이 형태 생성:

parameters.spin = 1;

gui
  .add(parameters, 'spin')
  .min(-5)
  .max(5)
  .step(0.001)
  .onFinishChange(generateGalaxy);

회전 각도 적용:

const spinAngle = radius * parameters.spin;
positions[i3] = Math.cos(branchAngle + spinAngle) * radius;
positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius;

Randomness

너무 정렬된 입자들을 무작위성으로 퍼뜨리기:

parameters.randomness = 0.2;

gui
  .add(parameters, 'randomness')
  .min(0)
  .max(2)
  .step(0.001)
  .onFinishChange(generateGalaxy);

x, y, z 축 모두에 무작위 값 적용:

const randomX = (Math.random() - 0.5) * parameters.randomness * radius;
const randomY = (Math.random() - 0.5) * parameters.randomness * radius;
const randomZ = (Math.random() - 0.5) * parameters.randomness * radius;

Randomness Power

더 자연스럽게 분산시키기 위해 Math.pow() 사용:

parameters.randomnessPower = 3;

gui
  .add(parameters, 'randomnessPower')
  .min(1)
  .max(10)
  .step(0.001)
  .onFinishChange(generateGalaxy);

음수 방향으로도 분포시키기 위해 -1을 곱함:

const randomX =
  Math.pow(Math.random(), parameters.randomnessPower) *
  (Math.random() < 0.5 ? 1 : -1) *
  parameters.randomness *
  radius;

Colors

중앙과 외곽의 색상을 다르게 설정:

parameters.insideColor = '#ff6030';
parameters.outsideColor = '#1b3984';

gui.addColor(parameters, 'insideColor').onFinishChange(generateGalaxy);
gui.addColor(parameters, 'outsideColor').onFinishChange(generateGalaxy);

재질 설정 시 vertexColors 활성화:

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

색상 속성 추가:

const colors = new Float32Array(parameters.count * 3);

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

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

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

마무리

이제 아름다운 은하 생성기가 완성되었습니다.
lil-gui로 다양한 파라미터를 조정하며 원하는 스타일의 은하를 만들어 보세요.
다양한 분포, 색상, 분기 수 등을 조절하며 실험해 보시길 바랍니다.

주의: 파티클 개수를 지나치게 높이면 컴퓨터가 뜨거워질 수 있습니다. 적절한 값으로 조절하세요.


import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import GUI from 'lil-gui';

/**
 * Base
 */
// Debug
const gui = new GUI({ width: 360 });

// Canvas
const canvas = document.querySelector('canvas.webgl');

// Scene
const scene = new THREE.Scene();

/**
 * Galaxy
 */
const parameters = {};
parameters.count = 10000;
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';

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

const generateGalaxy = () => {
  /**
   * Destroy old galaxy
   */
  if (points !== null) {
    geometry.dispose();
    material.dispose();
    scene.remove(points);
  }

  geometry = new THREE.BufferGeometry();

  const position = 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 branchesAngle =
      ((i % parameters.branches) / parameters.branches) * Math.PI * 2;
    const randomX =
      Math.pow(Math.random(), parameters.randomnessPower) *
      (Math.random() < 0.5 ? 1 : -1);
    const randomY =
      Math.pow(Math.random(), parameters.randomnessPower) *
      (Math.random() < 0.5 ? 1 : -1);
    const randomZ =
      Math.pow(Math.random(), parameters.randomnessPower) *
      (Math.random() < 0.5 ? 1 : -1);

    position[i3 + 0] = Math.cos(branchesAngle + spinAngle) * radius + randomX; // x
    position[i3 + 1] = randomY; // y
    position[i3 + 2] = Math.sin(branchesAngle + spinAngle) * radius + randomZ; // z

    // Color
    const mixedColor = colorInside.clone(); // 원본 변경 방지를 위해 복사
    mixedColor.lerp(colorOutside, radius / parameters.radius); // 섞기 (섞을 색, 0 적게 ~ 1 많이)

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

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

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

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

generateGalaxy();

gui
  .add(parameters, 'count')
  .min(100)
  .max(100000)
  .step(100)
  .onFinishChange(generateGalaxy);
gui
  .add(parameters, 'size')
  .min(0.01)
  .max(0.1)
  .step(0.01)
  .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(1)
  .onFinishChange(generateGalaxy);
gui
  .add(parameters, 'randomness')
  .min(0)
  .max(2)
  .step(0.001)
  .onFinishChange(generateGalaxy);
gui
  .add(parameters, 'randomness')
  .min(1)
  .max(10)
  .step(0.01)
  .onFinishChange(generateGalaxy);
gui.addColor(parameters, 'insideColor').onFinishChange(generateGalaxy);
gui.addColor(parameters, 'outsideColor').onFinishChange(generateGalaxy);

/**
 * Sizes
 */
const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
};

window.addEventListener('resize', () => {
  // Update sizes
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;

  // Update camera
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();

  // Update renderer
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

/**
 * Camera
 */
// Base camera
const camera = new THREE.PerspectiveCamera(
  75,
  sizes.width / sizes.height,
  0.1,
  100,
);
camera.position.x = 3;
camera.position.y = 3;
camera.position.z = 3;
scene.add(camera);

// Controls
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

/**
 * Animate
 */
const clock = new THREE.Clock();

const tick = () => {
  const elapsedTime = clock.getElapsedTime();

  // Update controls
  controls.update();

  // Render
  renderer.render(scene, camera);

  // Call tick again on the next frame
  window.requestAnimationFrame(tick);
};

tick();

'2D&3D > ThreeJS' 카테고리의 다른 글

20 물리엔진(Physics)  (2) 2025.07.29
19 스크롤 기반 애니메이션  (2) 2025.07.28
17 Particle(Points)  (3) 2025.07.28
16 Haunted House 프로젝트  (1) 2025.07.26
15 그림자(Shadow)  (3) 2025.07.26