My Boundary As Much As I Experienced

Creative Coding Lab 137.5의 모두의 연구소 첫 번째 전시에 참여합니다. (+Truth in Pendulum 구현 과정) 본문

Interactive(HTML Canvas ・three.js ・game)/CSS・Canvas ・ p5.js ・ Pixi.js

Creative Coding Lab 137.5의 모두의 연구소 첫 번째 전시에 참여합니다. (+Truth in Pendulum 구현 과정)

Bumang 2024. 7. 19. 11:19

크리에이티브 코딩 랩 137.5의 전시를 위해 p5.js로 진자운동으로 씬이 바뀌는 애니메이션을 구현했다.

https://truth-in-pendulum.vercel.app/

 

이 작품으로 모두의 연구소 강남점에서 2024. 07. 19. - 2024. 07. 27. 까지 전시한다.

다른 분들의 재밌는 작품들도 많으니 구경해볼 사람들은 잠깐 들러봐도 좋겠다.

(그나저나 포스터 깔쌈하게 잘 만들지 않았나? 내가 만들었다 히히)

 

 

0.  작품 컨셉

내 작품은 펜들럼이 정지하면 그 안의 인물의 내밀하고 시꺼먼 속이 보이는(?) 컨셉의 애니메이션이다.

원래 '펜들럼 안의 희노애락'을 컨셉으로 하려 했는데 '희노애락'은 이제 너무 전형적인 컨셉인거 같아서 변경했다.

가면 갈수록 추악한 분위기로 바뀌는 더 딥해지는 컨셉으로..

 

1. 사용한 라이브러리 p5.js

바닐라 자바스크립트와 p5.js를 사용했다.

그리고 html에 여러 자바스크립트를 모두 script태그로 임포트해왔다.

 

2. 그림 그리기

애니메이션 화풍의 아트는 아주 잘 만들지 않으면 아주 유치해보인다.

애니풍은 확실히 누구나 쉽게 시도해볼 수 있는 그림체인 만큼 그만큼 '보기 괜찮은 수준'의 허들은 높기 때문이다..

그러나 그림을 프로처럼 보일때까지 다듬을 여유는 없는 시점이니.. 최대한 인터랙션으로 커버해보려 했다.

 

+2024.7.20. 이제 카메라를 이용한 인터랙션도 넣을 생각이다. 카메라 피사체의 각도에 따라 약간의 패럴랙스를 구현할 생각이다.

 

3. 기본적인 작품의 구현 방안

- pendulum (eraser)

------------

- layerOuter

- layerInner

------------

- layerAlter (비노출)

 

1. layerOuter와 layerInner에 각각의 이미지를 겹쳐놓는다.

2. '추'가 지나가는 궤적에 맞춰 원형 eraser로 Outer부분을 지워준다. 그러면 진자 안에 Inner부분이 보이는 것처럼 보인다!

3. 진자가 거의 멈췄을 때 진자의 크기를 키우기 시작한다. 진자 안의 영역이 캔버스 영역을 충분히 채웠을 때, layerAlter에 있는 레이어를 Inner로, Inner를 Outer로, layerOuter에 있는 부분을 Alter로, 삼자 스위칭을 하고 '추'를 초기화하고 다시 떨어뜨린다.

 

4. 진자 운동 로직

진자운동을 자체를 구현한 로직은 이전 포스팅에서도 다룬 적 있다. https://bumang.tistory.com/197

 

1. 오리진 벡터를 만들고,

2. 좌표에 각속도를 더하여 새로운 좌표를 만든다.

3. 자연스러운 가속과 감속을 만드려면 가속도에 각가속도를 더해서 속도의 변화를 표현한다.

 

위 로직은 진자운동 뿐만이 아니라,

진자 추가 확대되는 속도를 표현할 때,

진자의 opacity가 줄어드는 것을 부드럽게 표현할 때 모두 쓰였다.

p5.js로 물리를 표현하기위한 기본적인 방법인 것이다.

  swingPendulumDecremently(decre = 0.99) {
    // push와 pop으로 그리기 상태를 저장할 수 있다.
    push(); // 현재의 그리기 상태를 저장
    let force = this.gravity * sin(this.angle);

    this.angleA = (-1 * force) / this.len;
    this.angleV += this.angleA;
    this.angle += this.angleV;

    this.angleV *= decre;

    // 추의 x, y
    this.bob.x = this.len * sin(this.angle) + this.origin.x;
    this.bob.y = this.len * cos(this.angle) + this.origin.y;

    // layerOuter에 pendulum이 그려짐
    layerOuter.stroke(255);
    layerOuter.strokeWeight(4);
    // 시작점의 x,y, bob의 x,y를 라인으로 이음
    layerOuter.line(this.origin.x, this.origin.y, this.bob.x, this.bob.y);
    // bob의 x, y를 중점으로 반지름 64의 원을 생성

    layerOuter.stroke(255, 255, 255);
    layerOuter.strokeWeight(8);
    layerOuter.fill(0, 0, 0, 0);
    layerOuter.circle(this.bob.x, this.bob.y, this.r + 10);
    layerOuter.erase();
    layerOuter.circle(this.bob.x, this.bob.y, this.r);

    // 추 이미지 그리기
    if (this.r > 500) {
      this.lensOpacity -= 10;
      this.drawLensImage(this.lensOpacity);
    } else {
      this.drawLensImage();
    }

    layerOuter.noErase();
    pop(); // 이전의 그리기 상태로 복원
  }

  drawLensImage(opacity = 255) {
    if (lensImage) {
      tint(255, opacity);
      // 이미지가 로드된 경우
      push();
      translate(this.bob.x, this.bob.y); // 추의 위치로 이동
      rotate(-this.angle); // 각도를 조정하여 이미지를 회전
      imageMode(CENTER); // 이미지 모드를 센터로 설정
      image(lensImage, 0, this.lensOriginY, this.lensWidth, this.lensHeight); // 추 이미지 그리기
      pop();
    }
  }

 

다음은 Pendulum 클래스 객체이다. 주석으로 일일이 다 설명을 달아놨으니 궁금하면 한 번 보시라.

// pendulum 객체
class Pendulum {
  constructor() {
    this.origin = createVector(width / 2, -1000); // 오리진 좌표

    this.angle = PI / 3; // 각도
    this.angleV = 0; // 각속도 초기화
    this.angleA = 0.001; // 각가속도 초기화

    this.len = 1400; // 줄 길이
    this.bob = createVector(); // '추'의 벡터
    this.gravity = 1; // 중력
    this.tempCount = 0;
    this.r = 400; // '추'의 반지금
    this.way = "RtL"; // '추'의 진행방향
    this.round = 0;
    this.scenes = [
      {
        color: "#E3B0AF",
        music: "NATURE",
      },
      {
        color: "#591E70", //
        music: "FORGIVEME",
      },
      {
        color: "#25032C", //
        music: "DREAM",
      },
    ];
    this.lensWidth = this.r * 3 - 40;
    this.lensHeight = this.r * 3 + 300;
    this.lensOriginY = -120;
    this.lensVY = 1;
    this.lensOpacity = 255;
  }

  // 펜들럼 세팅을 한 번에 설정하는 메소드. 패러미터 없이 그냥 호출하면 기본값으로 세팅된다.
  setPendulumSettings(
    origin = createVector(width / 2, -1000), //
    angle = PI / 2,
    angleV = 0,
    angleA = 0.001,
    len = 1400,
    gravity = 1,
    r = 400,
    way
  ) {
    this.origin = origin;
    this.angle = angle;
    this.angleV = angleV;
    this.angleA = angleA;
    this.len = len;
    this.gravity = gravity;
    this.r = r;
    this.way = way;
  }

  // 오리진 설정 메소드
  setPendulumOrigin(origin) {
    this.origin = origin;
  }

  // 각 설정 메소드
  setPendulumAngle(angle) {
    this.angle = angle;
  }

  // 각속도 설정 메소드
  setPendulumAngleVelocity(angleV) {
    this.angleV = angleV;
  }

  // 각가속도 설정 메소드
  setPendulumAngleAcceleration(angleA) {
    this.angleA = angleA;
  }

  // 반지름 (줄 길이) 설정 메소드
  setPendulumLength(len) {
    this.len = len;
  }

  // 중력 설정 메소드
  setPendulumGravity(gravity) {
    this.gravity = gravity;
  }

  // 확장된 렌즈 이미지를 초기값으로 되돌리는 메소드 
  resetLensImage() {
    this.lensWidth = this.r * 3 - 70;
    this.lensHeight = this.r * 3 + 270;
    this.lensOriginY = -120;
    this.lensVY = 1; 
    // lensVY: 추이미지가 정가운데 있는게 아니라 Y축으로 조금씩 올리면서 확장해야 
    // 정가운데에서 커지는 것처럼 보이기 때문에 Y축으로 좀 올려주기 위한 속도값이다.
    this.lensOpacity = 255;
  }

  // 추를 확장시키는 메소드 (진자 운동 거의 멈췄을 때)
  setBobExpand(inc = 1.005) {
    this.lensVY += 0.1;
    this.r *= inc;
    this;
    this.lensWidth *= inc - 0.001;
    this.lensHeight *= inc - 0.001;
    this.lensOriginY -= inc * this.lensVY;
    if (this.r > 2000) {
      this.r = 2000;
    }
  }

  // 위 set메소드들로 설정한 값들을 조회하는 get메소드.
  getPendulumStatus() {
    return {
      origin: this.origin,
      angle: this.angle,
      angleV: this.angleV,
      angleA: this.angleA,
      len: this.len,
      bob: this.bob,
      gravity: this.gravity,
      r: this.r,
      way: this.way,
      scenes: this.scenes,
      round: this.round,
      lensWidth: this.lensWidth,
      lensHeight: this.lensHeight,
    };
  }

  // 등속도 운동으로 추를 돌리는 메소드. (혹시나 필요할까 싶어서 만듦)
  swingPendulumContinuously() {
    // push와 pop으로 그리기 상태를 저장할 수 있다.
    push(); // 현재의 그리기 상태를 저장
    let force = this.gravity * sin(this.angle);

    this.angleA = (-1 * force) / this.len;
    this.angleV += this.angleA;
    this.angle += this.angleV;

    // 추의 x, y
    this.bob.x = this.len * sin(this.angle) + this.origin.x;
    this.bob.y = this.len * cos(this.angle) + this.origin.y;

    // layerOuter에 pendulum이 그려짐
    layerOuter.stroke(255);
    layerOuter.strokeWeight(4);
    // 시작점의 x,y, bob의 x,y를 라인으로 이음
    layerOuter.line(this.origin.x, this.origin.y, this.bob.x, this.bob.y);
    // bob의 x, y를 중점으로 반지름 64의 원을 생성

    layerOuter.stroke(75, 100, 255);
    layerOuter.strokeWeight(8);
    layerOuter.fill(0, 0, 0, 0);
    layerOuter.circle(this.bob.x, this.bob.y, this.r + 10);
    layerOuter.erase();
    layerOuter.circle(this.bob.x, this.bob.y, this.r);
    layerOuter.noErase();
    pop(); // 이전의 그리기 상태로 복원
  }

  // 감속 운동으로 추를 돌리는 메소드. (사실 이것만 씀)
  swingPendulumDecremently(decre = 0.99) {
    // push와 pop으로 그리기 상태를 저장할 수 있다.
    push(); // 현재의 그리기 상태를 저장
    let force = this.gravity * sin(this.angle);

    this.angleA = (-1 * force) / this.len;
    this.angleV += this.angleA;
    this.angle += this.angleV;

    this.angleV *= decre;

    // 추의 x, y
    this.bob.x = this.len * sin(this.angle) + this.origin.x;
    this.bob.y = this.len * cos(this.angle) + this.origin.y;

    // layerOuter에 pendulum이 그려짐
    layerOuter.stroke(255);
    layerOuter.strokeWeight(4);
    // 시작점의 x,y, bob의 x,y를 라인으로 이음
    layerOuter.line(this.origin.x, this.origin.y, this.bob.x, this.bob.y);
    // bob의 x, y를 중점으로 반지름 64의 원을 생성

    layerOuter.stroke(255, 255, 255);
    layerOuter.strokeWeight(8);
    layerOuter.fill(0, 0, 0, 0);
    layerOuter.circle(this.bob.x, this.bob.y, this.r + 10);
    layerOuter.erase();
    layerOuter.circle(this.bob.x, this.bob.y, this.r);

    // 추 이미지 그리기
    if (this.r > 500) {
      this.lensOpacity -= 10;
      this.drawLensImage(this.lensOpacity);
    } else {
      this.drawLensImage();
    }

    layerOuter.noErase();
    pop(); // 이전의 그리기 상태로 복원
  }

  // 가속운동으로 추를 점점 돌리는 메소드 (안씀)
  swingPendulumIncremently(incre = 1.01, maxAbs = 0.9) {
    // push와 pop으로 그리기 상태를 저장할 수 있다.
    push(); // 현재의 그리기 상태를 저장

    if (this.tempCount === 0) {
      this.angle = 0;
      this.tempCount++;
    }

    // 진자에 작용하는 중력의 힘 계산
    let force = this.gravity * sin(this.angle);
    this.angleA = (-1 * (force ? force : 0.1)) / this.len; // 각가속도 계산
    this.angleV += this.angleA; // 각속도 업데이트

    this.angleV *= 0.2; // 감쇠 효과 추가 (공기 저항 등)

    this.angle += this.angleV; // 각도 업데이트

    // 추의 위치 계산
    this.bob.x = this.len * sin(this.angle) + this.origin.x;
    this.bob.y = this.len * cos(this.angle) + this.origin.y;

    // layerOuter에 pendulum이 그려짐
    layerOuter.stroke(255);
    layerOuter.strokeWeight(4);
    // 시작점의 x,y, bob의 x,y를 라인으로 이음
    layerOuter.line(this.origin.x, this.origin.y, this.bob.x, this.bob.y);
    // bob의 x, y를 중점으로 반지름 64의 원을 생성

    layerOuter.stroke(75, 100, 255);
    layerOuter.strokeWeight(8);
    layerOuter.fill(0, 0, 0, 0);
    layerOuter.erase();

    if (OVERALL_SCENE === "NATURE") {
      drawlayerRabbit();
      drawlayerRacoon();
    }

    layerOuter.circle(this.bob.x, this.bob.y, this.r);
    layerOuter.noErase();
    pop(); // 이전의 그리기 상태로 복원
  }
  
  // 렌즈 이미지를 그리는 메소드
  drawLensImage(opacity = 255) {
    // 이미지가 로드된 경우
    if (lensImage) {
      tint(255, opacity);
      push();
      translate(this.bob.x, this.bob.y); // 추의 위치로 이동
      rotate(-this.angle); // 각도를 조정하여 이미지를 회전
      imageMode(CENTER); // 이미지 모드를 센터로 설정
      image(lensImage, 0, this.lensOriginY, this.lensWidth, this.lensHeight); // 추 이미지 그리기
      pop();
    }
  }  

  // layerOuter, layerInner, layerAlter를 서로서로 바꾸는 메소드
  convertScenes() {
    this.round++;

    let temp1 = layerOuterImage;
    let temp2 = layerInnerImage;
    let temp3 = layerAltImage;

    layerOuterImage = temp2;
    layerInnerImage = temp3;
    layerAltImage = temp1;

    return this.scenes[this.round % 3];
  }
}

 

5. Eraser 로직

p5.js의 erase 메소드로 추 안의 내부 범위만큼 지우면 된다.

layerOuter에서 erase함수로 외부레이어를 지운다. 그러면 안에 있던 Inner가 보이게 된다.

    // 펜들럼 클래스 - swingPendulumDecremently 메소드

    layerOuter.stroke(75, 100, 255);
    layerOuter.strokeWeight(8);
    layerOuter.fill(0, 0, 0, 0);
    layerOuter.erase(); // 지우기
    // ...

    layerOuter.circle(this.bob.x, this.bob.y, this.r); 추의 범위 만큼
    layerOuter.noErase(); // 지우기 끝
    pop();
  }

 

 

6. 시퀀스 전환 로직과 추의 확장 로직

- usePendulumStatus와 if문의 조합

- setTimeout과 flag를 이용한 타이밍 조작

- 추의 r값이 1600px을 넘어간 시점(캔버스를 꽉 채운 시점)에 확장

 

7. 음악 재생 로직 

최신 브라우저에선 사용자가 의도치 않은오디오 자동재생이 막혀있다.

그래서 mute된 상태로 사이트에 들어가서 사운드를 사용자가 직접 켜야만 소리가 재생되게 할 수 있다.

그런데 내 작품의 전시 환경에선 화면을 터치하기가 어려운 환경이라

오디오 자동재생을 어떻게든 구현하려고 별의 별 노력을 다 해봤다.

 

이에 관련된 내용은 아래 별도의 포스팅으로 대체하려고 한다.

 

https://bumang.blog/237

 

모던 브라우저에서 오디오 자동재생을 구현하기 위한 노력..

전시 작품에 클릭을 안 하고도 음악이 자동으로 나오게 해야된다.https://truth-in-pendulum.vercel.app/ 최신 브라우저에선 사용자가 의도치 않은오디오 자동재생이 막혀있다.그래서 mute된 상태로 사이

bumang.blog

 

8. SVG필터로 효과 넣기 - feTurbulance & feDisplacementMap

svg필터에 filter를 넣고 feTurbulance와 feDisplacementMap을 넣었다.

feTurbulance와 feDisplacementMap의 구체적인 개념과 사용방법은 아래와 같다.

feTurbulence

  • 목적: feTurbulence는 패턴이나 텍스처를 생성하기 위해 사용된다. 주로 난기류 효과를 만들어내며, 필터의 출력을 배경이나 다른 효과에 사용할 수 있다.
  • 기능: 무작위 패턴(프랙탈 노이즈 또는 터뷸런스)을 생성하여 그래픽 요소에 적용한다. 이 패턴은 자연스러운 효과(구름, 물, 불 등)를 시뮬레이션하는 데 유용하다.
  • 속성:
    • type: "fractalNoise" 또는 "turbulence"로 설정 가능. "fractalNoise"는 더 부드러운 노이즈를, "turbulence"는 더 날카로운 노이즈를 생성한다.
    • baseFrequency: 노이즈의 주파수를 결정하며, 값이 높을수록 패턴이 더 세밀해진다.
    • numOctaves: 노이즈의 복잡도를 결정합니다. 값이 높을수록 더 복잡한 패턴이 생성된다.
    • seed: 노이즈 생성의 시드 값으로, 동일한 시드 값은 동일한 패턴을 생성한다.

feDisplacementMap

  • 목적: feDisplacementMap는 입력 이미지를 왜곡시키기 위해 사용된다. 주로 이미지를 비틀거나 뒤틀리게 하여 시각적 효과를 만들어낸다.
  • 기능: 입력 이미지의 픽셀 위치를 feDisplacementMap 필터의 또 다른 입력 이미지의 색상 값에 따라 변경합니다. 이때 두 번째 이미지(맵)가 픽셀 이동의 크기와 방향을 결정한다.
  • 속성:
    • in: 왜곡될 원본 이미지.
    • in2: 왜곡에 사용할 두 번째 이미지. 이 이미지는 픽셀 이동을 정의한다.
    • scale: 왜곡의 강도를 조절합니다. 값이 높을수록 왜곡이 심해진다.
    • xChannelSelector와 yChannelSelector: in2 이미지의 어떤 채널(R, G, B, A)을 각각 x축과 y축의 왜곡에 사용할지 결정한다.

 

나는 아래와 같이 html파일에 필터를 넣어줬고,

    <svg width="0" height="0">
      <filter id="turbulence">
        <feTurbulence type="turbulence" baseFrequency="0.001" numOctaves="2" result="turbulence" />
        <feDisplacementMap in="SourceGraphic" in2="turbulence" scale="10" />
      </filter>
    </svg>

 

draw함수 안에서 씬에 따라 baseFrequncy와 duration을 바꿔서 intervalAnimation을 실행했다.

씬이 바뀌기 전까지는 통과하지 못하는 if문을 만들어 인터벌이 여러번 생성되는 것을 방지했다.

function draw() {
  const pendulumStatus = pendulum.getPendulumStatus();
  drawBlur(layerOuterImage, "DOWN");
  drawlayerInner();
  drawlayerOuter();

  useSceneController(curScene, { ...pendulumStatus });

  const { scenes, round } = pendulum.getPendulumStatus();
  const thisScene = scenes[round % 3];
  
  // Pendulum객체에서 현재 어떤 씬인가에 따라 OVERALL_SCENE 변수를 바꿔주었다.
  // feTurbulance 안에 있는 baseFrequncy를 바꿔주고
  // 지글거리는 빈도를 설정해주는 duration값도 바꿔준다.
  if (thisScene.music === "NATURE") {
    OVERALL_SCENE = "NATURE";
    baseFrequency = noise(xoff) * 0.0002;
    duration = 500;
  } else if (thisScene.music === "FORGIVEME") {
    OVERALL_SCENE = "FORGIVEME";
    baseFrequency = noise(xoff) * 0.0008;
    duration = 100;
  } else if (thisScene.music === "DREAM") {
    OVERALL_SCENE = "DREAM";
    baseFrequency = noise(xoff) * 0.004;
    duration = 50;
  }

  // 인터벌이 없으면 인터벌 애니메이션으로 turbulance값을 조정하는 인터벌 애니메이션을 재생한다.
  // inner키라고 if문 안에서만 할당되는 키값을 만들고
  // 이게 없거나, 현재 음악과 값이 다른 경우 조건문 안으로 진입을 허가한다.
  if (!interval || !innerKey || innerKey !== thisScene.music) {
    innerKey = thisScene.music;
    clearInterval(interval);
    interval = setInterval(() => {
      xoff += 1;

      turbulenceElement.setAttribute("baseFrequency", baseFrequency);
    }, duration);
  }
}

 

 

9. 최종 회고

p5.js는... 예약함수인 setup이나 draw등의 함수를 직접 실행시키지 않아도

라이브러리가 런타임 중 발견하면 알아서 실행시키는 신기한 라이브러리였다.

ESM에 익숙해져 있는터라, p5.js를 쓰는 commonJs 환경이 조금 불편하기도 했다.

그래도 p5를 이용한 인터랙티브 코딩 튜토리얼들이 꽤 많으니 학습 수단으로만 가끔 배우고,

앞으로는 Pixi를 더 배워봐야겠다.

 

그나저나 어떤 큰 청사진을 그리고 그걸 구체화해나가는 일은 언제나 즐겁다.

진자운동이란 주제를 가지고 어떻게 살릴지, 그림과 인터랙티브 코딩을 어떻게 혼합할지

막연한 기획을 조금씩 개선하고 개선하고.. 축적되는 결과물이 그럴듯해지면 뿌듯하고!

역시 나는 메이커로 사는게 적성에 맞긴 한거 같다.