Learning
레슨 6 / 8·2개 토픽

커스텀 컴포넌트

AFRAME.registerComponent 심화

A-Frame의 핵심은 커스텀 컴포넌트입니다. AFRAME.registerComponent()로 등록한 컴포넌트는 HTML 속성처럼 어떤 엔티티에든 붙일 수 있습니다. 컴포넌트는 라이프사이클 메서드(init, update, tick, remove, pause, play)를 가지며, schema로 외부에서 속성을 전달받습니다. 잘 설계된 컴포넌트는 재사용 가능하고, 여러 엔티티에 조합하여 복잡한 동작을 만들 수 있습니다.

javascript
// ── 회전 컴포넌트: 매 프레임 엔티티를 회전시킴 ──
AFRAME.registerComponent('spin', {
  schema: {
    axis: { type: 'string', default: 'y' },   // 회전 축: x, y, z
    speed: { type: 'number', default: 1 },     // 초당 회전 속도 (배율)
    enabled: { type: 'boolean', default: true }, // 활성화 여부
  },

  // init: 컴포넌트가 엔티티에 처음 붙을 때 1회 실행
  init: function () {
    this.rotation = { x: 0, y: 0, z: 0 };
    console.log('spin 컴포넌트 초기화:', this.data);
  },

  // update: schema 속성이 변경될 때 실행 (init 직후에도 호출)
  update: function (oldData) {
    if (oldData.speed !== this.data.speed) {
      console.log('속도 변경:', oldData.speed, '→', this.data.speed);
    }
  },

  // tick: 매 프레임 실행 (~60fps)
  // time: 장면 시작 후 경과 시간(ms), delta: 이전 프레임과의 시간 차(ms)
  tick: function (time, delta) {
    if (!this.data.enabled) return;

    const speed = this.data.speed * (delta / 1000) * 360;
    const axis = this.data.axis;
    const rotation = this.el.getAttribute('rotation');

    rotation[axis] = (rotation[axis] + speed) % 360;
    this.el.setAttribute('rotation', rotation);
  },

  // remove: 컴포넌트가 엔티티에서 제거될 때 정리
  remove: function () {
    console.log('spin 컴포넌트 제거됨');
  },

  // pause: 장면이나 엔티티가 일시정지될 때
  pause: function () {
    this.data.enabled = false;
  },

  // play: 장면이나 엔티티가 재개될 때
  play: function () {
    this.data.enabled = true;
  },
});
html
<!-- spin 컴포넌트 사용 -->
<a-box
  spin="axis: y; speed: 0.5"
  position="0 1 -3"
  color="#e74c3c"
></a-box>

<!-- X축 빠른 회전 -->
<a-sphere
  spin="axis: x; speed: 2"
  position="-3 1.5 -4"
  color="#3498db"
  radius="0.8"
></a-sphere>

<!-- 회전 비활성화 상태 -->
<a-cylinder
  spin="axis: z; speed: 1; enabled: false"
  position="3 1 -4"
  color="#2ecc71"
></a-cylinder>

복합 속성과 실전 컴포넌트

javascript
// ── 따라가기 컴포넌트: 대상 엔티티를 부드럽게 추적 ──
AFRAME.registerComponent('follow', {
  schema: {
    target: { type: 'selector' },     // 따라갈 대상 (CSS 선택자)
    speed: { type: 'number', default: 2 },
    offset: { type: 'vec3', default: { x: 0, y: 0, z: -2 } },
  },

  tick: function (time, delta) {
    if (!this.data.target) return;

    const targetPos = this.data.target.getAttribute('position');
    const currentPos = this.el.getAttribute('position');
    const offset = this.data.offset;
    const factor = this.data.speed * (delta / 1000);

    // 목표 위치 = 대상 위치 + 오프셋
    const goalX = targetPos.x + offset.x;
    const goalY = targetPos.y + offset.y;
    const goalZ = targetPos.z + offset.z;

    // 선형 보간(lerp)으로 부드럽게 이동
    currentPos.x += (goalX - currentPos.x) * factor;
    currentPos.y += (goalY - currentPos.y) * factor;
    currentPos.z += (goalZ - currentPos.z) * factor;

    this.el.setAttribute('position', currentPos);
  },
});

// ── 토글 가시성 컴포넌트 ──
AFRAME.registerComponent('toggle-visible', {
  schema: {
    event: { type: 'string', default: 'click' },
  },

  init: function () {
    const el = this.el;
    el.addEventListener(this.data.event, () => {
      const visible = el.getAttribute('visible');
      el.setAttribute('visible', !visible);
    });
  },
});

// ── 거리 기반 크기 변화 컴포넌트 ──
AFRAME.registerComponent('scale-on-distance', {
  schema: {
    minScale: { type: 'number', default: 0.5 },
    maxScale: { type: 'number', default: 2 },
    maxDistance: { type: 'number', default: 10 },
  },

  tick: function () {
    const camera = document.querySelector('[camera]');
    if (!camera) return;

    const camPos = camera.getAttribute('position');
    const myPos = this.el.getAttribute('position');
    const dx = camPos.x - myPos.x;
    const dy = camPos.y - myPos.y;
    const dz = camPos.z - myPos.z;
    const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);

    const t = Math.min(dist / this.data.maxDistance, 1);
    const s = this.data.minScale + t * (this.data.maxScale - this.data.minScale);
    this.el.setAttribute('scale', { x: s, y: s, z: s });
  },
});
html
<!-- follow 컴포넌트: 카메라를 따라다니는 안내 텍스트 -->
<a-entity
  text="value: 여기를 봐주세요!; align: center; color: #fff"
  follow="target: [camera]; speed: 1; offset: 0 -0.5 -3"
></a-entity>

<!-- scale-on-distance: 가까이 가면 작아지고 멀면 커지는 오브젝트 -->
<a-sphere
  scale-on-distance="minScale: 0.3; maxScale: 3; maxDistance: 15"
  position="0 2 -8"
  color="#9b59b6"
></a-sphere>
  • schema — 컴포넌트의 외부 설정값 정의 (type, default 등)
  • type: selector — CSS 선택자로 다른 엔티티를 참조
  • type: vec3 — {x, y, z} 벡터 (HTML에서 "1 2 3"으로 입력)
  • init() — 최초 1회 실행, 이벤트 리스너 등록에 적합
  • tick(time, delta) — 매 프레임 실행, 연속 동작 구현에 사용
  • update(oldData) — 속성 변경 감지, 동적 재설정에 사용
  • remove() — 정리 코드, 이벤트 리스너 해제 등
  • pause() / play() — 엔티티 활성/비활성 시 호출
💡

tick() 안에서 매 프레임 DOM 조회(querySelector)를 하면 성능이 저하됩니다. init()에서 한 번만 조회하고 this에 캐싱하세요. 또한 tick()에서 setAttribute를 과도하게 호출하면 렌더링 성능에 영향을 주므로, 변경이 필요한 경우에만 호출하는 조건 분기를 추가하는 것이 좋습니다.