728x90

GLB 파일 압축하기

웹에서 클라이언트가 웹서버에 리소스 요청을 할 때,  glb는 굉장히 많은 비용을 발생시킵니다. 이는 분명 성능 저하에도 영향을 준다고 생각이 들어서, 알아보았고 Draco를 통해서 glb파일을 압축할 수 있다는 사실을 알게 되었습니다.

 

Draco는 구글에서 개발한 3d파일 압축 기술이면  어떻게 압축을 효율적으로 하는지는 아래 논문에서 자세히 볼 수 있습니다..

(개인적으로 너무 어려워서 머리에 잘 담기진 않네요 ㅠㅠ)

https://www.kibme.org/resources/journal/20230807171156346.pdf

 

그리고 이를 쉽게 사용할 수 있게 하는 오픈 소스라이브러인 'glTF Pipeline' 을 사용해서 압축하였습니다.

 

이중 내가 사용할 명령어는 -i (변경할 파일)  -o (변경된 파일명)  -d  (드라코 압축 옵션) 입니다.

gltf-pipeline -i model.gltf -o modelDraco.gltf -d
 
이를 바탕으로 압축하였을 때, 아래와 같이 background 같이 큰 파일은 50% 가까이 압축되었고, 그 외에도 10~15프로 정도 압축이 되었습니다.

(기존 파일 명을 2라고 수정하고 변경완료된 파일명을 기존 파일명으로 했습니다.)

 

압축 전 후 비교

 

이제 설레는 마음을 가지고 얼만큼 절약이 되었을지 확인만 하면 됩니다.

 

퍼포먼스 탭을 통해서 성능 비교하기

우선 next.js에 layout에 있는 header가 먼저 렌더링 됩니다.

이후 page.js를 요청하고, 현재 suspense로 캔버스의 loading 처리를 하고 있습니다. 그리고 Shiba에서 shiba glb파일, Background에서 background.glb 파일을 사용하고 있습니다.

export default function LuckyShiba() {
  return (
    <div className="wrapper_3d">
      <Suspense fallback={<Loading />}>
        <SessionProvider>
          <CanvasLayout color={color.bg} camera={{ position: [0, 2, 4] }}>
            <Physics gravity={[0, -9.8, 0]}>
              <ambientLight />
              <directionalLight position={[0, 5, 5]} />
              <Shiba />
              <Walls />
              <Background />
              <TutorialOpener />
            </Physics>
          </CanvasLayout>
        </SessionProvider>
      </Suspense>
    </div>
  );
}

 

비교를 명확하기 위해서 실험은 빠른 3G 환경에서 하였습니다.

 

 

현재 제 사이트에서 새로 고침 후 html을 서버에 요청을 하게 되면 header와 관련된 부분과 page.js를 요청하고 header가 가볍기에 먼저 렌더링이 됩니다. 이후 page.js 요청이 끝나면 loading 처리가 시작되면서 glb파일들을 요청을 합니다.

 

사진이 좀 작아서  값을 표기하면 크기 시간 순으로 background.glb를 압축 전 후 비교하면

압축 후 : 246B , 624 밀리초 

압축 전 : 246B , 651 밀리초

 

생각보다 차이가 나지 않아서 여러번 했었느데, 위 시간과 큰 차이가 나진 않더군요..

분명 git에서 Bin파일이 압축되었음을 확인에도 불구하고 제가 생각한 것 이상의 차이는 않네요..

 

 

좀 더 자세한 비교를 위해서 성능 탭을 보았는데도 유의미한 결과를 호가인하진 못했습니다.

 

 

 

결론

1. 또한 glb파일보다 canvas 가 들어 있는 page.js 파일을 받아오는데 더 많은 리소스를 차지 (약 55배?) 확인할 수 있었습니다.

2. 성능의 차이가 존재하지만, 실제 압축된 용량에 비해서는 성능의 차이가 생각보다 크지 않았다.
--> 왜에 대해서 분석하고 싶은데,, 당장은 결론을 얻진 못헀습니다..

 

 

 

 

728x90

최근 프로젝트의 기능 구현이 마무리가 되었습니다. 하지만 아직 클린코드의 관점에서 미숙한 부분이 많습니다.

이를 값, 계산 , 액션으로 분리하기와  solid원칙을 준수하기를 바탕으로 리팩토링하려고 합니다.

 

코드 원문과 이미지는 아래와 같습니다.

import { useState } from 'react';
import Tutorial1 from './Tutorial1';
import Tutorial2 from './Tutorial2';
import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { MANUAL_SKIP } from '@/shared/contants';

export default function GuidePopUp() {
  const [step, setStep] = useState<number>(0);
  const stepChanger = (direction: 'before' | 'after') => {
    const lastStep = 1;
    if (direction === 'before') {
      if (step > 0) {
        setStep(step - 1);
      }
    } else {
      if (step < lastStep) {
        setStep(step + 1);
      }
    }
  };

  const handleDontShowAgain = () => {
    localStorage.setItem(MANUAL_SKIP, 'true');

    const escEvent = new KeyboardEvent('keydown', { key: 'Escape' });
    document.dispatchEvent(escEvent);
  };

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>
        {step === 0 && <Tutorial1 />}
        {step === 1 && <Tutorial2 />}
      </div>
      <button className={styles.hideButton} onClick={handleDontShowAgain}>
        다시 보지 않기
      </button>
      <div className={styles.naviWrapper}>
        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('before');
          }}
        >
          이전
        </button>
        {step + 1 + '/' + 2}

        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('after');
          }}
        >
          다음
        </button>
      </div>
    </div>
  );
}

이런 지저분한 코드를 관심사를 분리해서

import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { tutorials } from './content';
import { useStep } from './hook/useStep';
import HideButton from './control/HideButton';
import Navigation from './control/Navigation';

export default function GuidePopUp() {
  const lastStep = tutorials.length;
  const [step, stepChanger] = useStep(lastStep);

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>{tutorials[step]}</div>
      <HideButton />
      <Navigation step={step} stepChanger={stepChanger} totalSteps={lastStep} />
    </div>
  );
}

이렇게 만드는 과정입니다.

 

그러기 위해선 우선 화면을 관심사에 따라서 세분화 합니다.

 공통적으로 존재하는 제목 영역, step에 따라 바뀌어야 하는 내용영역, 그리고 공통적으로 존재하는 버튼과 현재 step 영역이 존재합니다.

 

내용영역 분리하기

이 중에서 우선 가운데의 내용부분을 분리하려고 합니다.

 

내용의 경우에는 줄바꿈이나, 강조하고 싶은 색깔이 다르기 때문에 공통적인 요소로 묶기 힘드니, 개별적인 컴퍼넌트로 작성하였습니다. 이때 내용의 경우에는 html과 css를 활용해서 꾸민 '값'입니다.

따라서 Popup 컴퍼넌트에서 값을 분리하였습니다.

 

import Tutorial1 from './Tutorial1';
import Tutorial2 from './Tutorial2';

export const tutorials = [<Tutorial1 key={1} />, <Tutorial2 key={2} />];

 

import { useState } from 'react';

import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { hideManualByDefault } from './util';
import { tutorials } from './content';

export default function GuidePopUp() {
  const lastStep = tutorials.length;
  const [step, setStep] = useState<number>(0);

  const stepChanger = (direction: 'before' | 'after') => {
    if (direction === 'before') {
      if (step > 0) {
        setStep(step - 1);
      }
    } else {
      if (step < lastStep) {
        setStep(step + 1);
      }
    }
  };

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>{tutorials[step]}</div>

      <button className={styles.hideButton} onClick={hideManualByDefault}>
        다시 보지 않기
      </button>
      <div className={styles.naviWrapper}>
        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('before');
          }}
        >
          이전
        </button>
        {step + 1 + '/' + 2}

        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('after');
          }}
        >
          다음
        </button>
      </div>
    </div>
  );
}

 

이제 Tutorial 페이지가 늘어나도,  GuidePopup의 코드에는 아무런 영향이 가지 않습니다.

또한 기존에는 직접 lastStep을 지정해줬는데, 배열의 길이를 통해 마지막 단계가 어딘지 구하는 로직으로 변경되었습니다.

카카오의 아티클을 보고 다형성 기반으로 개방 폐쇠 법칙도 적용하고 싶었는데, 외부에서 불러오는 데이터도 아니고 단순한 값이기에 그 정도의 깊이 있는 분리가 나오지는 않았네요..

 

커스텀 훅으로 튜토리얼 단계변경 액션 분리하기

import { useState } from 'react';

export const useStep = (lastStep: number) => {
  const [step, setStep] = useState<number>(0);
  const stepChanger = (direction: 'before' | 'after') => {
    setStep((prevStep) => {
      if (direction === 'before' && prevStep > 0) {
        return prevStep - 1;
      }
      if (direction === 'after' && prevStep < lastStep) {
        return prevStep + 1;
      }
      return prevStep;
    });
  };

  return [step, stepChanger] as const;
};

 

해당 함수의 관심사는 이전과 다음 단계로 이동시키는 것입니다.

따라서 이를 useStep이라는 훅으로 분리하고, 튜플형태로 return 시켰습니다.

https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state

 

useState – React

The library for web and native user interfaces

react.dev

에서 set 함수의 경우 콜백함수 형태로 사용시 이전 상태를 사용할 수 있습니다. 따라서 이전 상태를 prevStep으로 명명하고 조건문을 얼리 리턴 기법을 사용하여 가독성을 향상시켰습니다.

 

import { useState } from 'react';

import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { hideManualByDefault } from './util';
import { tutorials } from './content';
import { useStep } from './hook/useStep';

export default function GuidePopUp() {
  const lastStep = tutorials.length;
  const [step, stepChanger] = useStep(lastStep);

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>{tutorials[step]}</div>

      <button className={styles.hideButton} onClick={hideManualByDefault}>
        다시 보지 않기
      </button>
      <div className={styles.naviWrapper}>
        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('before');
          }}
        >
          이전
        </button>
        {step + 1 + '/' + 2}

        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('after');
          }}
        >
          다음
        </button>
      </div>
    </div>
  );
}

 

이제 마지막으로  버튼 영역을 분리하려고 합니다.

 버튼 영역 분리하기

우선 숨기기 버튼부터 분리하였습니다.

import React from 'react';
import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { MANUAL_SKIP } from '@/shared/contants';
import { closeModalByEvent } from '@/shared/components/portal/util';

export default function HideButton() {
  const hideManualByDefault = () => {
    localStorage.setItem(MANUAL_SKIP, 'true');
    closeModalByEvent();
  };

  return (
    <button className={styles.hideButton} onClick={hideManualByDefault}>
      다시 보지 않기
    </button>
  );
}

기존에는 util 폴더에서 hideManualByDefault 함수를 관리하였는데, 숨기기 버튼이 분리되면서 굳이 따라 관리할 필요성을 느끼지 못했습니다. 따라서 해당 함수를 HideButton 컴퍼넌트와 결합시켰습니다.

 

이후 버튼도 page 변경용도의 버튼들이기에 Navigation이라는 이름으로 분리시켰습니다.

import React from 'react';
import styles from '@/app/luckyshiba/luckyshiba.module.css';

interface NavigationProps {
  step: number;
  stepChanger: (direction: 'before' | 'after') => void;
  totalSteps: number;
}

export default function Navigation({
  step,
  stepChanger,
  totalSteps,
}: NavigationProps) {
  return (
    <div className={styles.naviWrapper}>
      <button
        className={styles.naviButton}
        onClick={() => stepChanger('before')}
      >
        이전
      </button>
      {step + 1 + '/' + totalSteps}
      <button
        className={styles.naviButton}
        onClick={() => stepChanger('after')}
      >
        다음
      </button>
    </div>
  );
}

 

최종적으로 완성된 컴퍼넌트는

import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { tutorials } from './content';
import { useStep } from './hook/useStep';
import HideButton from './control/HideButton';
import Navigation from './control/Navigation';

export default function GuidePopUp() {
  const lastStep = tutorials.length;
  const [step, stepChanger] = useStep(lastStep);

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>{tutorials[step]}</div>
      <HideButton />
      <Navigation step={step} stepChanger={stepChanger} totalSteps={lastStep} />
    </div>
  );
}

 

느낀점

원티드 프리온보딩에서 멘토님께 배운 값, 계산, 액션으로 분리하고 최근 아티클을 읽은 내용을 바탕으로 solid하게 작성하고자 하였습니다.

 

간단한 메뉴얼 컴퍼넌트였기에 SOLID패턴중에서 아티클에서 읽고 느낀 모든 내용을 반영하지는 못하였습니다.

하지만 해당 아티클에서 말하였듯이 SOLID에서 가장 핵심은 Single Responsibility Principle 이며, 관심사의 분리에 집중하여 리팩토링을 진행하였습니다.

또한 SOLID원칙을 요약하는건 간단하지면 각각의 키워드에 맞게 작성하는 과정은 좀 더 깊이 공부하고 생각할 필요성을 느꼈습니다.

아직 완벽하진 않을 수 있지만, 초기 코드에 비해서는 훨씬 가독성이 향상되었다는 점에서 보람을 느꼈습니다.

 

참고문헌

https://fe-developers.kakaoent.com/2023/230330-frontend-solid/

 

프론트엔드와 SOLID 원칙 | 카카오엔터테인먼트 FE 기술블로그

임성묵(steve) 판타지, 무협을 좋아하는 개발자입니다. 덕업일치를 위해 카카오페이지로의 이직을 결심했는데 인사팀의 실수로 백엔드에서 FE개발자로 전향하게 되었습니다. 인생소설로는 데로

fe-developers.kakaoent.com

https://www.perssondennis.com/articles/write-solid-react-hooks

 

Persson Dennis - Write SOLID React Hooks

Write SOLID React Hooks June 2, 2024 (19 minutes reading) SOLID is one of the more commonly used design patterns. It's commonly used in many languages and frameworks, and there are some articles out there how to use it in React as well. Each React article

www.perssondennis.com

 

원티드 프리온보 오종택 멘토님 강의

 

728x90

들어가며

초기에는 간단하게 position을 통해서 전진 후진 회전만 구현하였습니다.

https://ungumungum.tistory.com/121

 

R3F에서 시바를 키보드 입력을 통해 회전시키기(트러블 슈팅)

들어가며좀 더 interactive한 웹을 만들기 위해서 최근 three.js 기반의 R3F 라이브러리를 활용하여, 웹 페이지를 만들고 있습니다.강아지를 활용하여서 이동하게 하려고 하는데, 강아지의 이동 및 회

ungumungum.tistory.com

 

총 4가지의 방법을 도입했었고, 결국 어떤 방법을 도입했는지에 대한 설명을 하고자 합니다.

1. position 기반 이동시키기

2. velocity 기반 이동시키기

3. force 기반 이동시키기

4. chassicBody를 이용한 자동차형으로 이동시키기

 

그리고 R3F에서 위치를 이동시키기 위해선 키보드 이벤트를 감지하여 이동시켜야 합니다. 

이때 useInput 커스텀훅을 사용하여서 wasd space를 감지하였고,, 이를 기반으로 이동을 시키는데, 2가지 방식으로 이동이 가능합니다.

1. useEffect : 상태가 변화했을 때 실행
    2번과 4번같은 경우에는 물체의 속도를 정하고, 바퀴의 회전속도를 정하여서 이동합니다. 따라서 전진 후진등의 버튼의      상태가 변화했을 때 적용시키면 됩니다.

2. useFrame : 매 프레임마다 상태를 확인하여 실행

    1번과 3번의 position을 이동시키거나 힘을 가하는 방식에서는 매 프레임 확인하여 위치를 변경해야합니다.

 

이 중에서 어떤 방식을 왜 기각했고, 결국 왜 position 기반 이동 방식을 도입하게 되었는지에 대해서 작성하고자 합니다.

 

제가 고민 및 도입했던 과정은 1 4 3 2 1 순이며, 1번의 경우 앞선 글에서 설명했으니, 4 3 2 1 순으로 설명하고자 합니다.

 

각각의 방식 도입과 기각 이유

chassicBody를 이용한 자동차형으로 이동

도입배경은 useCannon에서 충돌체가 불안정하기 때문입니다. 참고 : https://ungumungum.tistory.com/122

 

R3F에서 물체가 지나갈 수 있는 언덕 만들기(react-three/cannon)

아래와 같은 환경에서  언덕을 만들어주려고 합니다.cannon-es 공식문서에서 간단하게 Trimesh를 이용해서 삼각형을 만드는 법을 알려주는데,  이를 기반으로 react-three/cannon에서 삼각형 모양의 기

ungumungum.tistory.com

 

box나 cylinder 이외의 다른 충돌체의 경우 충돌이 불안정하였고, 이중 cylinder가 타 충돌체와의 상호작용이 안정적이었습니다.

따라서 다리를 만들어하는 제 입장에서 바퀴가 cylinder인 방식을 도입한다면 좋을 것이라 판단하였습니다.

 

useCannon에서는 기본적으로 자동차 엔진과 유사한 형식으로 작동하는 훅(useRayCastVehicle)을 제공하고 있습니다.

(useCannon은 결국 cannon을 확장해서 만든거라 설명은 cannon-es가 더 자세합니다.)

 

이를 기반으로 바퀴를 조정하는 바퀴 및 이동 물체의 정보를 받아서, 조종하는 훅을 만들었습니다. (관심사 분리가 명확하지 않은점은 죄송합니다 ㅠ.)

//useMoveCar.ts
import { useEffect, useRef } from 'react';
import { useInput } from './useInput';
import {
  PublicApi,
  RaycastVehiclePublicApi,
  useRaycastVehicle,
} from '@react-three/cannon';
import { Group, Object3DEventMap, Quaternion, Vector3 } from 'three';
import { useWheels } from './useWheels';

type MoveCarProps = {
  chassisApi: PublicApi;
  chassisBody: React.RefObject<Group<Object3DEventMap>>;
  worldDirection: Vector3;
  bodyInfo: {
    width: number;
    height: number;
    front: number;
  };
};

export const useMoveCar = ({
  chassisApi,
  worldDirection,
  chassisBody,
  bodyInfo,
}: MoveCarProps) => {
  const { forward, backward, left, right, jump, stand } = useInput();
  const engineForce = 400;
  const velocity = useRef([0, 0, 0]);
  const { width, height, front } = bodyInfo;
  const [wheels, wheelInfos] = useWheels({ width, height, front });

  const [vehicle, vehicleApi] = useRaycastVehicle(
    () => ({
      chassisBody,
      wheelInfos,
      wheels,
    }),
    useRef<Group>(null)
  );

  useEffect(() => {
    chassisApi.velocity.subscribe((v) => (velocity.current = v));

    if (forward) {
      vehicleApi.applyEngineForce(engineForce, 2);
      vehicleApi.applyEngineForce(engineForce, 3);
    } else if (backward) {
      vehicleApi.applyEngineForce(-engineForce, 2);
      vehicleApi.applyEngineForce(-engineForce, 3);
    } else {
      vehicleApi.applyEngineForce(0, 2);
      vehicleApi.applyEngineForce(0, 3);
    }

    if (left) {
      vehicleApi.setSteeringValue(0.35, 2);
      vehicleApi.setSteeringValue(0.35, 3);
      vehicleApi.setSteeringValue(-0.1, 0);
      vehicleApi.setSteeringValue(-0.1, 1);
    } else if (right) {
      vehicleApi.setSteeringValue(-0.35, 2);
      vehicleApi.setSteeringValue(-0.35, 3);
      vehicleApi.setSteeringValue(0.1, 0);
      vehicleApi.setSteeringValue(0.1, 1);
    } else {
      for (let i = 0; i < 4; i++) {
        vehicleApi.setSteeringValue(0, i);
      }
    }
    if (jump) {
      const [x, y, z] = velocity.current;
      chassisApi.velocity.set(x * 1.2, y + 7, z * 1.2);
    }

    if (stand) {
      const rotateQuaternion = new Quaternion().setFromAxisAngle(
        new Vector3(0, 1, 0),
        Math.PI / 2
      );
      const currentQuaternion = new Quaternion().setFromUnitVectors(
        new Vector3(1, 0, 0),
        worldDirection
      );
      currentQuaternion.multiply(rotateQuaternion); // y축 회전 적용
      chassisApi.quaternion.set(
        currentQuaternion.x,
        currentQuaternion.y,
        currentQuaternion.z,
        currentQuaternion.w
      ); // 쿼터니언을 직접 설정
    }
  }, [
    backward,
    stand,
    worldDirection,
    chassisApi,
    forward,
    jump,
    left,
    right,
    vehicleApi,
  ]);

  return { vehicle, vehicleApi };
};

 

기능은 총 4가지로 만들었습니다.  
1. 전후진 , 2. 좌우 바퀴회전 , 3.  점프(바퀴가 공중에 뜨기 때문에 x.z축으로 약간의 가속), 4. 서기(전복 시 사용)

 이동과 trimesh로 만든 다리를 건너는 기능까지는 가능합니다. 

 

문제점

하지만 여러 단점이 발견되었고, 결국 일반적인  Box로 움직이도록 하기로 결정하였습니다. 

1. 하지만 문제점이 언덕에서 잘 미끄러짐.

2. 바퀴의 크기가 작으면 몸체가 충돌하여 언덕 잘 못 올라감

3. 바퀴가 크면 언덕을 올라가기전에 종종 충돌해서 멈춤

4. 시바가 자동차처럼 움직여서 부자연스러움

 

force 를 이용한 이동

  const [chassisBody, chassisApi] = useCompoundBody(
    () => ({
      position,
      mass: mass,
      rotation: [0, 0, 0],
      collisionFilterGroup: 5,
      angularDamping: 0.95,
      onCollide: () => {
        !isLanded && setIsLanded(true);
      },
      shapes: [
        {
          args: chassisBodyArgs,
          position: [0, 0, 0],
          type: 'Box',
        },
      ],
    }),
    useRef<Group>(null)
  );
chassicApi.applyForce([x,y,z])

 

다음과 같이 cannon의 충돌체에는 힘을 가할 수 있는 옵션이 있습니다. 이를 바탕으로 시바가 바라보고 있는 방향으로 힘을 가하여 이동시키는 로직에, 회전의 경우 기존 y축 기준으로 회전시키는 방법을 도입하면 어떤가 했습니다.

 

문제점

기본적으로 -y축 방향으로 9.8의 중력이 가해지고 있습니다. 이때 힘을 가하여 이동시키니 시바가 이동하는게 아닌 구르기 시작했습니다.. 

 

그렇기에 회전시에 마찰력을 존재하게 해서 구르지 못하도록 하는 방식을 도입하려고 했습니다.

위의 코드의 useCompoundBody 훅을 보시면 angularDamping이라는 각회전시 마찰력이 존재합니다. 이를 1로 하면 회전하지 않게 되는데, 이런 경우에는 언덕을 못 올라가는 문제가 발생하였습니다..

언덕을 볼 때는 30도 위를 쳐다봐야하는데, 그러지 않으니 힘을 가해도 언덕에 부딪히기만 하는 문제가 생겼습니다.

 

velocity 를 이용한 이동

chassicApi.velocity.set(x,y,z)

마찬가지로 속력을 가하여 이동시키는 방식을 도입하였는데, 해당 방식의 경우에 이동은 제대로 되었습니다.

언덕도 올라갈 수 있고, 적당한 angularDamping (0.95)를 설정시 구르지도 않게 되었습니다.

 

문제점 

하지만  전진할 때 속력을 부여하는 방식은 점프와 같이 쓰기엔 불안정했습니다.

일반적인 이동에서는 y방향으로의 속력을 0으로 설정합니다. 문제는 공중에 떠 있을 때 입니다.

중력의 영향을 받기에 내려가야하는데 , y축의 속력이 0이 아니기에 낙하하는 속력을 구해서 직접 반영해야합니다.

 

매번 shiba의 y축 속력을 구해서 이동시키는건 너무 비효율적인 방식이어서 최종적으로 position을 기반으로 이동하는 방식을 도입하였습니다.

 

 

position 방식과 코드

추후에 이벤트 발생과도 연계해야했기에, 점프를 하고 있다면 전역변수를 통해 착륙하지 않음(isLanded = false)를 만들었고, 타 물체와 충돌 시에 착륙으로 만드는 로직을 구현하였습니다.

import { Group, Object3DEventMap, Quaternion, Vector3 } from 'three';
import { useInput } from './useInput';
import { PublicApi } from '@react-three/cannon';
import { useMemo } from 'react';
import { useShibaStore } from '@/store/shiba';
type MovePositionProps = {
  worldPosition: Vector3;
  worldDirection: Vector3;
  chassisApi: PublicApi;
  chassisBody: React.RefObject<Group<Object3DEventMap>>;
};

export const useMovePosition = ({
  worldDirection,
  worldPosition,
  chassisApi,
  chassisBody,
}: MovePositionProps) => {
  const { forward, backward, left, right, jump, stand } = useInput();
  const worldQuaternion = useMemo(() => new Quaternion(), []);
  const { isLanded, setIsLanded, blockEvent, eventable } = useShibaStore();
  const controlMovement = (delta: number) => {
    if (forward || backward) {
      const speed = delta * 7;
      let { x, y, z } = worldPosition;
      let { x: rx, y: ry, z: rz } = worldDirection;
      let [newX, newY, newZ] = [x, y, z];

      if (forward) {
        newX += rx * speed;
        newZ += rz * speed;
      }
      if (backward) {
        newX -= rx * speed;
        newZ -= rz * speed;
      }
      chassisApi.position.set(newX, newY, newZ);
    }

    if (right || left) {
      const turnAngle = delta;
      const turnQuaternion = new Quaternion();
      chassisBody?.current!.getWorldPosition(worldPosition);

      if (right) {
        turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), -turnAngle);
      }
      if (left) {
        turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), turnAngle);
      }
      worldQuaternion.multiplyQuaternions(turnQuaternion, worldQuaternion);
      chassisApi.quaternion.set(
        worldQuaternion.x,
        worldQuaternion.y,
        worldQuaternion.z,
        worldQuaternion.w
      );
    }

    if (jump) {
      if (isLanded) {
        setIsLanded(false);
      }
      chassisApi.velocity.set(0, 5, 0);
    }
  };

  return controlMovement;
};

 

728x90

도입 배경 및 폰트 준비

 

2D로 택스트를 작성해서 넣었습니다.. 물론 단지 2D라서 그런건 아니지만 입체감이 있으면 좀 더 잘 보이고 이쁠 거 같아서 3d텍스트를 도입하기로 했습니다.

 

https://github.com/pmndrs/drei?tab=readme-ov-file#text3d

 

GitHub - pmndrs/drei: 🥉 useful helpers for react-three-fiber

🥉 useful helpers for react-three-fiber. Contribute to pmndrs/drei development by creating an account on GitHub.

github.com

이떄 사용할 것은 drei의 text3d입니다.

스토리북으로 실습도 할 수 있는데, 해당 컴퍼넌트를 사용하려면 json형식의 font가 필요하다고 하네요.

이는 drei의 text3d는 three.js의 textGeometry기반으로 만들어졌기 때문입니다.

 

따라서  폰트를 다운받고, (시바와 어울리는 동글동글한 글씨체를 찾았습니다)

 

폰트를 json파일로 변환 (링크)

 

 

하지만 wasd space 이정도의 글자만 사용할려고 하는데,, 용량이 좀 크네요...

 

우선 폰트 먼저 적용해보려고 합니다

 

폰트 적용

우선 폰트 먼저 적용해보려고 합니다

import { Center, Text, Text3D } from '@react-three/drei';

export default function Manual() {
  const fontUrl = '/font/ONE_Mobile_POP_Regular.json';

  const fontStyle = {
    font: fontUrl,
    size: 0.2,
    letterSpacing: 0.01,
    height: 0.02,
    fontSize: 2,
  };
  return (
    <group>
      <group>
        <Text3D position={[-0.12, 0.57, 0]} {...fontStyle}>
          이동
          <meshBasicMaterial color={'#654321'} />
        </Text3D>
        <Text3D position={[0, 0.27, 0]} {...fontStyle}>
          W
        </Text3D>
        <Text3D position={[-0.26, 0, 0]} {...fontStyle}>
          A S D
        </Text3D>
      </group>
      <group position={[0, 2, 0]}>
        <Text3D position={[0, 0.35, 0]} {...fontStyle}>
          비행
          <meshBasicMaterial color={'#654321'} />
        </Text3D>
        <Text3D position={[-0.26, 0, 0]} {...fontStyle}>
          SPACE
        </Text3D>
      </group>
    </group>
  );
}

 

 

위치는 조정해야겠지만 글자 생김새는 마음에 드네요.. 색을 사실 잘 못정하겠네요 ㅠㅠ 색이 같으면 너무 안 보일거 같고,,너무 독특하면 튈거같아서 국방색 느낌으로 조합했습니다..

 

폰트 파일 최적화

json형식의 웹폰트는 글리프( 하나 이상의 문자를 시각적으로 표현하기 위해 타이포그래피에서 사용되는 용어)로 구성되어있습니다. 이 중에서 사용하는 글리프만 추출하여 json파일의 크기를 줄여볼려고합니다

 

import fontjson from './ONE_Mobile_POP_Regular.json';
export default function Manual() {

  // 사용하는 font 최적화
  useEffect(() => {
    const fontData = fontjson;
    const targetText = '이동WASDPCE비행카메라 제어마우스 활용';
    const modifiedGlyphs = {};

    for (const char of targetText) {
      const charKey = fontData.glyphs[char]
        ? char
        : fontData.glyphs[char.toUpperCase()]
        ? char.toUpperCase()
        : null;
      if (charKey) {
        modifiedGlyphs[charKey] = fontData.glyphs[charKey];
      }
    }

    const modifiedFontData = {
      ...fontData,
      glyphs: modifiedGlyphs,
    };
    console.log(JSON.stringify(modifiedFontData));
  }, []);

콘솔에 사용하는 글리프들을 확인하고 이를 json파일로 덮어씌웁니다.

 

그  결과  900배 정도 크기가 감소했네요..

 

최종 위치는 아래와 같이 벽에 배치했습니다. 색깔도 베이지 색으로,,

728x90

문제점 및 해결과정

 

프로젝트를 진행하는 도중 아래와 같은 에러가 발생했다. 문제점은 한동안 개발자 도구의 메시지를 선택해놓고 작업을 해서 언제부터 발생했는지 못하는 상황이다..

 

마침 구글링을 해보니 관련 issue가 논의되었어서 해당 이슈를 참고하였다.

https://github.com/vercel/next.js/discussions/22388

 

Warning: Extra attributes from the server: style · vercel next.js · Discussion #22388

I'm not very experienced with Next.js/React, please forgive me if my question is dumb. As a first project, I tried to migrate my existing static website to NextJS. So far everything works, except o...

github.com

 

사람들이 많은 추론을 하고 있었는데 
1. 프토퍼티를 잘못 적었다.

2. 스타일을 잘못했다.

3. ul li 문젠거 같다.

4. 특정 스타일을 지우니 해결됐다. 

5. extension 문제다

 

우선적으로 내 코드에 문제가 있는지 확인하기 위해서 옛날 commit 시점으로 돌아가보았다. 그런데 분명히 해당 에러가 발생하지 않았던 시점에서도   동일한 에러가 발생하고 있었다.

따라서 가장 합리적인 추론은 5번 extension이다.

 

하지만 사람들마다 지웠다는 extension도 다르고,,, 일단 하나씩 확인해보기로 했다.

 

 

내 경우에는 ZeroBlur이었다...

 

해당 extensition을 지우니 에러가 사라졌다.

 

 

느낀점

에러는 굉장히 다양한 원인에서 발생한다.. 그런데 모듈간의 결합에 의한 에러는 굉장히 찾기 힘들고, 아직.. 왜 그런지에 대한 해답을 찾기가 너무 어렵다...

next.js와 R3F drei의 html의 결합에서 생긴 에러도 아직 명확히 해결하지 못했었다..

아직 성장할 길이 많이 남았다는 것을 느꼈다..

728x90

 

아래와 같은 환경에서  언덕을 만들어주려고 합니다.

cannon-es 공식문서에서 간단하게 Trimesh를 이용해서 삼각형을 만드는 법을 알려주는데,  이를 기반으로 react-three/cannon에서 삼각형 모양의 기둥을 만들어주려고 합니다.

https://pmndrs.github.io/cannon-es/docs/classes/Trimesh.html

 

  // 꼭짓점 좌표
  const x = 5;
  const y = 3;
  const z = 10;
  const vertices = [
    0,
    0,
    0, // 꼭짓점 1
    x,
    y,
    0, // 꼭짓점 2
    x,
    0,
    0, // 꼭짓점 3
    0,
    0,
    -z, // 꼭짓점 4
    x,
    y,
    -z, // 꼭짓점 5
    x,
    0,
    -z, // 꼭짓점 6
  ];

  // 삼각형 인덱스
  const indices = [
    0,
    1,
    2, // 앞쪽 삼각형
    3,
    4,
    5, // 뒤쪽 삼각형
    0,
    1,
    4, // 왼쪽 사각형 위
    0,
    4,
    3, // 왼쪽 사각형 아래
    1,
    2,
    5, // 오른쪽 사각형 위
    1,
    5,
    4, // 오른쪽 사각형 아래
    0,
    2,
    5, // 아래쪽 사각형
    0,
    5,
    3, // 아래쪽 사각형
  ];

  const [ref] = useTrimesh(() => ({
    args: [vertices, indices],
    position: [0, 0, 0],
    rotation: [0, Math.PI, 0],
    mass: 100,
    type: 'Static',
    collisionFilterGroup: 3,
  }));

indices는 각각의 꼭지점을 잇는 것이고

해당 번호는 vertices에 있는 3개의 점이 각각 x,y,z축으로 하나의 꼭짓점이 됩니다.

 

하지만 위 사진을 보면 알 수 있듯이 문제점이 발생하였습니다. 시바 충돌체가 언덕안에 들어가 있느 것이죠.. 그런데 언덕이 정작 바닥 위에는 떠있는 상황입니다.

 

문제 상황

Trimesh가 중력에는 영향을 받고, 바닥과는 충돌을 하는데 시바랑은 전혀 충돌을 하고 있지 않습니다.

react-three/cannon 및 cannon-es에서 속성 등,, 공식 문서를 이것 저것 많이 찾고  고생을 했었는데, 아래와 같은 표를 확인하게 됩니다.

2024-06-04일 기준

https://pmndrs.github.io/cannon-es/docs/index.html

 

문제는 바닥과는 충돌하는데 시바랑은 충돌하지 않는 것이었쬬,

시바는 Box , 바닥은 Plane,...  react-three/cannon도 결국 cannon-es를 이용해서 만든 라이브러리이기에 마찬가지 이겠쬬,,,

 

해결 방안 모색

두 가지 해결 방법이 있다고 생각합니다. 

1. Sphere를 이용한다. 

그냥 sphere를 이용하면 시바가 데굴데굴 구르겠죠.. 하지만 언덕에서만 구르도록 박스 안에 구를 넣는 방식으로 구현을 한다면 언덕을 이용할 수 있을 겁니다..

 

2. 언덕을 벽이 아닌 Box로 만든 후 기울인다.

이 방법을 사용하면 결국 언덕과 유사한 효과를 만들 수 있겠군요.

 

3. 바퀴 형식을 도입하여, 움직인다.

바퀴를 sphere로 하고 회전 시키는 경우에 시바는 데굴데굴 구르지 않을 겁니다.

해당 방법을 도입하였지만,, 움직임이 부자연스러워서  기각,,

 

최종 방법은  언덕을 만들어서 기울이기로 하였습니다.

position 관리에 용이하게 하기 위해서 useCompoundBody를 사용하였고,

아래와 같이 값을 분리하여 사용하였습니다

  const [houseBody, _] = useCompoundBody(
    () => ({
      mass: 0,
      rotation: [0, 0, 0],
      collisionFilterGroup: 3,
      type: 'Static',
      shapes: [...HOUSE_GROUND, ...HOUSE_SHAPE, ...HOUSE_STAIR],
    }),
    useRef(null)
  );
export const HOUSE_SHAPE: CompoundShape = [
  {
    //굴뚝
    args: [1.2, 10, 1.2],
    position: [-12.3, 11.9, 0.8],
    type: 'Box',
  },
  {
    //집
    args: [4.2, 5.5, 4],
    position: [-10.3, 9.5, 3],
    type: 'Box',
  },
  {
    args: [5, 5.5, 1],
    position: [-10.3, 12.8, 0.8],
    rotation: [Math.PI / 5, 0, 0],
    type: 'Box',
  },
  {
    args: [5, 5.5, 1],
    position: [-10.3, 12.8, 3.5],
    rotation: [-Math.PI / 5, 0, 0],
    type: 'Box',
  },
  {
    args: [8, 1.5, 5.5],
    position: [-11.3, 10.5, 2.5],
    type: 'Box',
  },
  {
    args: [4, 1.5, 4],
    position: [-13.5, 12.2, 2.5],
    rotation: [0, 0, Math.PI / 3.3],
    type: 'Box',
  },
];

export const HOUSE_STAIR: CompoundShape = [
  {
    args: [4, 1.5, 2],
    position: [-8, 1.9, -9],
    rotation: [0, 0, -Math.PI / 5.1],
    type: 'Box',
  },
  {
    args: [4, 1.5, 2],
    position: [-10, 3, -8.3],
    rotation: [0, Math.PI / 4, -Math.PI / 5.1],
    type: 'Box',
  },
  {
    args: [4, 1.5, 2.4],
    position: [-10.7, 5, -6],
    rotation: [0, Math.PI / 2, -Math.PI / 5.1],
    type: 'Box',
  },
  {
    args: [4, 6, 0.5],
    position: [-12, 3.5, -5.7],
    rotation: [Math.PI / 4, Math.PI / 2, -0.5],
    type: 'Box',
  },
  {
    args: [3, 2, 4],
    position: [-12, 1, -7.7],
    rotation: [-0.2, 0, 0.2],
    type: 'Box',
  },
];

 

힘들었던 점

 

이중에서 useConvexPolyhedron을 사용해봤다. 복잡한 다면체를 구현하기에 효과적인 방법 중 하난데 꼭지점과 꼭지점을 연결하여 면을 만들고, 해당 면의 수직축을 정하여서 충돌체를 만드는 방식이다.

 

다만 cannon-es 에 의해 공식 문서 스펙대로 구현하였으나 (vertices 꼭지점, faces 면, normals 면이바라보는 방향) 충돌이 올바르게 일어나지 않았다. ( 통과할 때도 있고 통과하지 않을때도 있고,, 불안정한 기능)

   const width = 4;
   const length = 17;
   const vertices: Triplet[] = [
     [9.5, 4.5, width],
     [9.5, 4.5, 0],
     [7, 4.5, width],
     [7, 4.5, 0],
     [0, 2.5, width],
     [0, 2.5, 0],
     [length, 2.5, width],
     [length, 2.5, 0],
     [0, 0, width],
     [0, 0, 0],
     [length, 0, width], //10
     [length, 0, 0], //11
     [10, 2.5, width],
     [10, 2.5, 0],
     [7, 2.5, width],
     [7, 2.5, 0],
   ];

   const faces = [
     [0, 1, 2, 3],
     [2, 3, 4, 5],
     [0, 1, 6, 7],
     [4, 5, 8, 9],
     [6, 7, 10, 11],
     [10, 11, 12, 13],
     [8, 9, 14, 15],
     [12, 13, 14, 15],
     [5, 9, 3, 15],
     [2, 4, 8, 14],
     [0, 2, 12, 14],
     [1, 3, 13, 15],
     [0, 12, 6, 10],
     [1, 7, 13, 11],
   ];

   function calculateNormal(v0, v1, v2) {
     const vector1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];

     const vector2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];

     const normal = [
       vector1[1] * vector2[2] - vector1[2] * vector2[1],
       vector1[2] * vector2[0] - vector1[0] * vector2[2],
       vector1[0] * vector2[1] - vector1[1] * vector2[0],
     ];

     const length = Math.sqrt(
       normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]
     );

     return [normal[0] / length, normal[1] / length, normal[2] / length];
   }

   const normals = faces.map((face) => {
     const [v0, v1, v2] = face.map((index) => vertices[index]);
     return calculateNormal(v0, v1, v2);
   });

   console.log(normals, 'normals');

 

따라서 차선으로 다리같은 복잡한 물체도 box의 조합으로 구현하였다.

728x90

들어가며

좀 더 interactive한 웹을 만들기 위해서 최근 three.js 기반의 R3F 라이브러리를 활용하여, 웹 페이지를 만들고 있습니다.

강아지를 활용하여서 이동하게 하려고 하는데, 강아지의 이동 및 회전에 대해서 학습한 점과 트러블슈팅을 기록 및 공유하고자 작성하게 되었습니다.

 

간단한 배경 설명 

SketchFab  에서 얻은 Shiba 모델을 tsx컴퍼넌트화 하였습니다.  해당 모델은 아쉽게도 animation이 없더군요.. 그렇지만 

@react-three/cannon 라이브러리를 통해 만든 물리엔진속에서 강아지가 움직일 수 있도록 하게 하고 싶었습니다.

     <group ref={chassisBody} {...props}>
        <group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
          <mesh
            geometry={nodes.Group18985_default_0.geometry}
            material={materials['default']}
          />
          <mesh
            geometry={nodes.Box002_default_0.geometry}
            material={materials['default']}
          />
          <mesh
            geometry={nodes.Object001_default_0.geometry}
            material={materials['default']}
          />
        </group>
      </group>

 

const position: [x: number, y: number, z: number] = [0, 1, 0];
  const width = 0.65;
  const height = 1.2;
  const front = 0.6;
  const mass = 50;

  const chassisBodyArgs = [width, height, front * 2];

  const [chassisBody, chassisApi] = useCompoundBody(
    () => ({
      position,
      mass: mass,
      rotation: [0, 0, 0],
      collisionFilterGroup: 5,
      shapes: [
        {
          args: chassisBodyArgs,
          position: [0, 0, 0],
          type: 'Box',
        },
      ],
    }),
    useRef(null)
  );

 

useCompoundBody 훅은 충돌체로 지정하기 위해서 사용하는데,(복합체의 경우에 사용하는데 위처럼 하나의 대상인 경우, useBox를 사용해도 됩니다.) 위에 설정한 옵션을 기반으로 대상을 정의하였습니다.

 

이후  키보드 입력을 다룰 useInput 훅을 생성하였습니다.

wasd를 누를 때  해당 값을 boolean형태로 기억하여, forward가 true면 전진 하는 식으로 로직을 구상하려 합니다.

(점프의 경우는 수정할 것 같습니다..)

import { useEffect, useState } from 'react';

type InputState = {
  forward: boolean;
  backward: boolean;
  left: boolean;
  right: boolean;
  jump: boolean;
};

type KeyMap = {
  [key: string]: keyof InputState;
};

export const useInput = (): InputState => {
  const [input, setInput] = useState<InputState>({
    forward: false,
    backward: false,
    left: false,
    right: false,
    jump: false,
  });

  useEffect(() => {
    const keys: KeyMap = {
      KeyW: 'forward',
      KeyS: 'backward',
      KeyA: 'left',
      KeyD: 'right',
      Space: 'jump',
    };

    const findKey = (key: string): keyof InputState | undefined => keys[key];

    const handleKeyDown = (e: KeyboardEvent): void => {
      const key = findKey(e.code);
      if (key) {
        setInput((prevInput) => ({ ...prevInput, [key]: true }));
      }
    };

    const handleKeyUp = (e: KeyboardEvent): void => {
      const key = findKey(e.code);
      if (key) {
        setInput((prevInput) => ({ ...prevInput, [key]: false }));
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    document.addEventListener('keyup', handleKeyUp);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.removeEventListener('keyup', handleKeyUp);
    };
  }, []);

  return input;
};

 

이제 키보드 이벤트가 성공적으로 입력되니 이를 바탕으로 모델을 움직이기만하면 됩니다.

앞서 Shiba의 경우 useComboundBody 훅으로 얻은 튜플값들 (chassisBody, chassisApi)을 활용하여 제어하면 됩니다.

 

기본적으로 @react-three/cannon 라이브러리의 경우에 cannon-es를 react에서 쓸 수 있도록 확장하였습니다.

 

트러브 슈팅 

우선 회전대상을 제어하려면 chassisBody, chassisApi을 활용해야 할 것 입니다.

각각 어떤 속성을 가지고 있는지 확인하기 위해서 콘솔을 우선 찍어 보았습니다.

 

Body
Api

 

quaternion이 무엇인지 잘 몰랐어서,, rotation을 활용하여 회전된 방향으로 이동을 시키면 되지 않을까? 라는 생각을 했습니다. 결론 부터 말하자면 quaternion은 Unitiy에서 주로 쓰는 회전을 다루기 위해서 쓰는 값입니다..

이걸 미리 알았으면 편했을텐데,,,

 

 

1. 내장 함수를 통해 위치와 회전 정도를 구한 후 회전시킴

  useFrame((state, delta) => {
    chassisApi.position.subscribe((pos) => {
      let [x, y, z] = pos;
      chassisApi.rotation.subscribe((rot) => {
        let [rx, ry, rz] = rot;
        if (forward) {
          x += Math.sin(ry) * delta;
          z += Math.cos(ry) * delta;
        }
        if (backward) {
          x -= Math.sin(ry) * delta;
          z -= Math.cos(ry) * delta;
        }
        if (right) {
          ry -= delta;
        }
        if (left) {
          ry += delta;
        }
        chassisApi.position.set(x, y, z);
        chassisApi.rotation.set(rx, ry, rz);
      });
    });
  });

 

콜뱀함수를 통해 실행하다 보니 많은 부하가 걸림. 프레임마다 시켜서 그런 것 같습니다.. 따라서

current에 있는 값을 쓰면 되지 않을까 확인해보니, 해당 값은 초기 설정값만 나오고 중력에 의해서 위치가 변경되어도 똑같은 값이 계속 나왔습니다..

 

 

 따라서 '로직을 변경하면 나아지지 않을까?' 생각하게 되었고, 콜백함수 형식에서 state에 값을 할당하는 방식으로 변경하였습니다.

 

2. 내장 함수를 통해 위치와 회전 정도를 구한 후 회전시킴

  const [chassisPosition, setChassisPosition] =
    useState<[number, number, number]>(position);
  const [chassisRotation, setChassisRotation] = useState<
    [number, number, number]
  >([0, 0, 0]);

  useEffect(() => {
    const unsubscribePosition = chassisApi.position.subscribe((pos) => {
      setChassisPosition(pos as [number, number, number]);
    });

    const unsubscribeRotation = chassisApi.rotation.subscribe((rot) => {
      setChassisRotation(rot as [number, number, number]);
    });

    return () => {
      unsubscribePosition();
      unsubscribeRotation();
    };
  }, [chassisApi]);

  useFrame((state, delta) => {
    let [x, y, z] = chassisPosition;
    let [rx, ry, rz] = chassisRotation;

    if (forward) {
      x += Math.sin(ry) * delta;
      z += Math.cos(ry) * delta;
    }
    if (backward) {
      x -= Math.sin(ry) * delta;
      z -= Math.cos(ry) * delta;
    }
    if (right) {
      ry -= delta;
    }
    if (left) {
      ry += delta;
    }

    chassisApi.position.set(x, y, z);
    chassisApi.rotation.set(rx, ry, rz);
  });

 

하지만 여전히 문제점이 있었습니다.  회전값의 경우에 ry가 왼쪽 90에서 최솟값( - π)오른쪽 90도에서 최댓값( π)

앞 뒤에서는 ry값이 모두 0이 나옵니다.

즉 값을 단순히 더하거나 빼서는 안됩니다.  이때 앞면 뒷면에 따라서 rx의 부호가 바뀌기 때문에 이를 분기 삼아서 처리할 수 있습니다.. 하지만  회전 및 이동하다 보면 버벅거리는 버그가 여전히 생겼습니다..

 

따라서,, 좀 더 공부할 필요성을 느끼고, cannon-es 및 react-three/cannon 의 소스 코드를 참고하였습니다.

 

 

해결 방법

앞서 얘기했듯이 회전을 수월하게 해주 Quaternion을 활용했습니다.

1. 우선 시바의 위치, 회전, 회전축을 기록할 각각의 변수를 생성합니다.

  const worldPosition = useMemo(() => new Vector3(), []);
  const worldDirection = useMemo(() => new Vector3(), []);
  const worldQuaternion = useMemo(() => new Quaternion(), []);

 

2. 시바를 따라다닐 카메라를 설치하였는데, 이 과정에서 chassicBody의 함수인 getWorldPosition을 통해 시바의 위치를 변수에 할당해줍니다.

 const makeFollowCam = () => {
    chassisBody?.current!.getWorldPosition(worldPosition);
    chassisBody?.current!.getWorldDirection(worldDirection);
    pivot.position.lerp(worldPosition, 0.9);
  };

 

3. 해당 값을 활용하여 이동 및 회전을 시킵니다.

const controlMovement = (delta: number) => {
    if (forward || backward) {
      const speed = delta * 2;
      let { x, y, z } = worldPosition;
      let { x: rx, y: ry, z: rz } = worldDirection;

      let [newX, newY, newZ] = [x, y, z];

      if (forward) {
        newX += rx * speed;
        newZ += rz * speed;
      }
      if (backward) {
        newX -= rx * speed;
        newZ -= rz * speed;
      }
      chassisApi.position.set(newX, newY, newZ);
    }

    if (right || left) {
      const turnAngle = delta;
      const turnQuaternion = new Quaternion();

      if (right) {
        turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), -turnAngle);
      }
      if (left) {
        turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), turnAngle);
      }
      worldQuaternion.multiplyQuaternions(turnQuaternion, worldQuaternion);
      chassisApi.quaternion.set(
        worldQuaternion.x,
        worldQuaternion.y,
        worldQuaternion.z,
        worldQuaternion.w
      );
    }
  };

 

 

 

이동 로직은 비슷하나 회전 로직을 수정하였습니다.  회전의 경우 left,right의 경우에만 이동의 경우 forward, backward의 경우에만 하므로 조건문을 통해 분기처리 하였습니다.

 

그리고 해당 코드를 useFrame훅에서 실행해줍니다.

 useFrame((_, delta) => {
    makeFollowCam();
    controlMovement(delta);
  });

 

followCam의 경우에 fastcampus 강의에서 배웠떤 것을 사용하였는데, 자동차 이동과 달리 카메라가 부자연스럽네요.. 그래도 시바의 회전이 잘 되고 있습니다.

 

 

느낀점

R3F도 그렇고  react-three/cannon 도 그렇고 기반이 되는 라이브러리에 대한 학습이 우선시 되어야 한다고 느꼈습니다.

또한 아직 관심사의 분리면에서 코드가 불안정해 보여서 코드를 develop할 필요성을 느끼고 있습니다.

보통 중력이나 힘을 이용해서 물체의 이동을 많이 구현하는데, 다른 방법으로 구현하는 과정이 재밌었네요.. 

 

728x90

시험 준비 배경

채용 공고를 보면 자주 적혀있는 내용 중 하나가 정보처리기사 자격증 보유 여부이다. 사실 첫 부트캠프를 마치고 나서는 전공생도 아닌 내가 준비하기엔 어려울 거 같다는 막연한 느낌에 미뤘었다. 

하지만 프론트엔드를 공부하면서 그저 내가 했던 방법이 나쁘지 않아서 또는 너무 불편해서 찾게 되는 경우가 아니면  기존 것을 계속 사용하게 된다. 단지 몰랐기 때문에 개선하지 못하고 있는 점들이 있지 않을까? 라는 고민을 점점하게 되었다.

또한 소프트웨어 용어에 대한 정확한 정의를 모르면서 추상적인 느낌으로 사용하는 단어도 존재했다.

 

이런 고민들이 섞여서 내가 정보처리기사를 공부하면서 얻고 싶은 점은 3가지다.

1. 내 프론트엔드 세상을 소프트웨어의 영역으로 확장하고 싶다.

2. 비전공자로서 가지는 어느 정도의 부족함 및 자격지심을 해결하고 싶다. 

3. 혹시 내가 추상적으로 사용하고 있던 개념들이 보편적인 개념들과 부합하는지 확인하고 싶다.

 

 

시험 준비 과정

필기의 경우에는 사실 '3일만에 합격했다' ,'1주일만에 합격했다 '등의 후기를 많이 보게 되었다.

한국사 시험을 합격하면서 문제집 뱅크방식의 자격증 시험에서는 기출문제 위주로 학습하면 큰 지식 없이도 합격할 수 있다는 사실을 직접 경험하기도 했었다.

하지만 단지 시험에만 합격하는 것은 내 초기 목표에 부합하지 않는다. 따라서 2주 반정도의 기간을 두고 시험을 준비했다. (너무 길지도 짧지도 않게) 150여개의 챕터를 하나씩 읽으며 내가 알고 있던 지식은 어떤 것이 있고, 부족한 부분은 어떤 것이 있냐에 대해서 생각했다.

 

1장 : 좋은 컴퍼넌트는 어떤 것일까? 어떻게 하면 UX를 향상할 수 있을까? 팀원과 잦은 소통을 하기 위해선 애자일 방식을 도입해봐야겠다. 등 평소에 프론트엔드를 공부를 하면서 했던 고민들과 겹치는 부분이 많아서 재밌었다.

 

2장 : 1장과 마찬가지로 테스트 코드, 복잡도,  데이터베이스등 평소에 자주 접했던 그리고 생각했던 용어가 많았다.

 

3장 : SQL에서 풀스텍 부트캠프에서 얕게나마 배웠던 게 많이 도움 되었다.

 

4장 : C언어와 java에 대해서 잘 아는 편은 아니었지만, 자바스크립트와 유사한 점이 있어서 수월했다. (C언어의 주소값 이런 부분은 조금 어려웠었다.)

 

흔히 면접질문 요약본등에 자주 나오는 내용도 많았다.

 

5장 : 1장이랑 조금 겹치는 느낌 많이 받았고, 장애나 보안 관련 부분에 지식이 부족해서 해당 부분 위주로 학습했다.

 

확인과 확장이라는 관점에서 공부하는 내내 생각보다 재밌었다.

 

시험 결과

공부 그 자체도 재밌고 의미 있었지만 , 아무래도 증명을 위해선 결과도 뒷받침 되어야 한다고 생각한다. 떨리는 마음으로 시험을 치러갔는데, 생각보다 시험이 빨리 끝났다. 5월 9일 12시 20분에 도착해서 40분에 입실, 1시에 시험을 바로 시작했다. 시험 시간은 30~40분 정도 걸렸다. 

 

가채점 결과는 평균 79점이 나왔고 , 어렵다 생각했던 5장은 오히려 기출문제와 많이 겹쳐서 95점이 나왔고, 3 4 장에 7개씩 틀렸다...

 

 

느낀점

시험이나 커리큘럼은 누군가의 의의가 담겨 있다. 내가 순수히 배우고 싶어서 딴 첫 번째 자격증이었고, 

 막연하게 썼던 애자일 방식에 대한 복습, 네트워크와 보안에 대한 학습, 용어에 대한 이해 등 많은 걸 얻을 수 있는 공부였다.

부족한 점을 좀 더 보완해서 실기까지 한 번에 합격하고 싶다.

 

728x90

Next.js에서는 auth.js ( 구 Next.auth)를 통해서 유저 인증(authentication )및 권한 부여(authorization)를 손쉽게 구현 할 수 있습니다.

 

Oauth를 활용하여 유저 인증을 하는 방법은 앞선 게시글에서 다루었고, 권한 설정에 대해서 다루어 볼려고 합니다.

https://ungumungum.tistory.com/110

 

Auth.js로 Oauth 구현하기 (예시 : google)

NextAuth가  다양한 프레임워크를 지원하기 위해서 확장되면서 Auth.js로 바꼈습니다.그러면서 사용법도 살짝 바꼈고 공식문서를 보고 학습한 부분을 포스팅합니다. 설치방법1. Auth.js설치npm install

ungumungum.tistory.com

 

 

방법 1. session을 통해서 다루기

우선 auth.ts 파일에서 유저 정보를 얻을 수 있는 auth함수를 반환하고 있습니다.

//auth.ts
import NextAuth, { NextAuthConfig } from 'next-auth';
import Google from 'next-auth/providers/google';

export const authConfig = {
  theme: { logo: 'https://authjs.dev/img/logo-sm.png' },
  providers: [Google],
} satisfies NextAuthConfig;

export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);

 

이를 활용하여 sesion 정보의 유무를 확인할 수 있고, 만약 로그인 되어있지 않다면 원하는 주소로 redirect 시킬 수 있습니다. 

import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function Mypage() {
  const session = await auth();
  if (!session) return redirect('/');
  return <main>로그인해야 볼 수 있는 페이지</main>;
}

 

다만 이렇게 할 경우 모든 페이지에 대해서 동일한 코드가 반복되어야 합니다. 또한 관심사가 분리되지 않아서, 어떤 페이지가 권한이 필요한지 한눈에 파악하기가 힘듭니다.

 

방법 2.  config에서 callback 설정하기

Next.js의 공식문서 tutorial에서는 아래와 같은 코드를 통해  접근권한을 관리합니다.

import type { NextAuthConfig } from 'next-auth';

export const authConfig = {
// authorized에서 false가 반환될 때 이동할 로그인 페이지(signIn)
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
    // user가 있으면 로그인 없으면 로그인 되지 않은 상태
      const isLoggedIn = !!auth?.user;
      // url의 경로가 /dashboard로 시작하는지
      const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
      
      // dashboard에서는 권한이 필요하므로, 비로그인시 login페이지로 이동
      if (isOnDashboard) {
        if (isLoggedIn) return true;
        return false; // Redirect unauthenticated users to login page
      } else if (isLoggedIn) {
      // 만약 로그인 된 유저라면 홈에서 origin과 더해서 /dashboard로 이동
        return Response.redirect(new URL('/dashboard', nextUrl));
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

config설정의 callbacks를 통하여서 권한 확인할 수 있는데,  이중 authorized는 next.js의 middleware와 함께 사용해서 

true를 반환하면, 권한 인증, false를 반환하면 권한이 없다는 것이다.

 

저는 위 코드를 기반으로 mypage에 접속햇는데 비로그인시 /로 이동하도록 하였습니다.

export const authConfig = {
  pages: {
    signIn: '/',
  },
  theme: { logo: 'https://authjs.dev/img/logo-sm.png' },
  callbacks: {
     authorized({ auth, request: { nextUrl } }) {
       const isLoggedIn = !!auth?.user;
       const protectedPath = ['/mypage'];
       const isProtected = protectedPath.includes(nextUrl.pathname);
       if (isProtected) {
         if (isLoggedIn) return true;
         return Response.redirect(new URL('/', nextUrl));
       }
       return true;
     },
   },
  providers: [Google],
} satisfies NextAuthConfig;

 

이제 위 코드를 적용하기 위해서는 middleware에 등록해야 합니다.

https://authjs.dev/reference/nextjs#authorized

 

미들웨어 등록

//middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth';

export default NextAuth(authConfig).auth;

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

 

next.js에서는 middleware 파일에서 미들웨어등록을 할 수 있습니다.

이때 config에 matcher를 통해서 언제 미들웨어를 사용할 지 결정할 수 있는데,

정규식의 부정형 전방 탐색을 활용하여

 

  1. api
  2. _next/static
  3. _next/image
  4. .png로 끝나는 문자열

을 포함한 경우에는 middleware가 실행되지 않도록 하였습니다.
( next.js에서는  불필요한 요청을 줄이기 위해서 cache가 진행되는데, _next/static , _next/image는 각각 cache된 파일들의 경로입니다.)

Middleware allows you to run code before a request is completed. Then, based on the incoming request, you can modify the response by rewriting, redirecting, modifying the request or response headers, or responding directly.
// https://nextjs.org/docs/pages/building-your-application/routing/middleware

 

이렇게 미들웨어를 사용하는 경우에는 특정페이지 요청전에 미들웨어가 실행되어 불필요한 요청을 막을 수 있습니다.

 

next.js에서 파일의 실행순서는 아래와 같습니다.

  1. headers from next.config.js
  2. redirects from next.config.js
  3. Middleware (rewrites, redirects, etc.)
  4. beforeFiles (rewrites) from next.config.js
  5. Filesystem routes (public/, _next/static/, pages/, app/, etc.)
  6. afterFiles (rewrites) from next.config.js
  7. Dynamic Routes (/blog/[slug])
  8. fallback (rewrites) from next.config.js

 

참고 문헌

https://nextjs.org/docs/pages/building-your-application/routing/middleware

 

Routing: Middleware | Next.js

Learn how to use Middleware to run code before a request is completed.

nextjs.org

 


https://authjs.dev/reference/nextjs#callbacks

 

 

Auth.js | Nextjs

Authentication for the Web

authjs.dev

https://authjs.dev/reference/nextjs#authorized

 

Auth.js | Nextjs

Authentication for the Web

authjs.dev

 

728x90

웹 접근성이란  장애인, 고령자 등이 웹 사이트에서 제공하는 정보에 비장애인과 동등하게 접근하고 이해할 수 있도록 보장하는 것입니다.

 

이를 향상시키기 위해서 시멘틱한 코드를 작성할 필요가 있는데, 단순히 alt를 적고 aria 태그를 이용하고 시멘틱 태그를 사용한다고 해서 과연 적절하게 작성했는지에 대해서는 확신할 수 없습니다.

 

따라서 저는 NVDA의 Screen Reader를 통해서 시각 장애인이 실제 내 사이트에 접근했을 때 어떻게 읽을지에 대해서 확인해 보았습니다.

 

 

사이트는 간단하게 학원 주소 이미지, 학원 이름, 오시는 길, 연락처등이 있는 페이지입니다.

(상호가 일점육수학과학전문학원이라서 검색에 불편함을 겪는 학부모님들을 위한 사이트)

 

사이트 기능이 단순한 만큼 Screen Reader로 읽었을 때 큰 문제가 없을거라 생각했는데,  2가지 불편함을 확인하여 이를 개선하였습니다.

 

#1. --> 으로 인한 불편함

오시는 길 옆에 화살표는 마우스를 호버했을 때 버튼이라는 시각적 효과를 주기 위해서 삽입하였습니다.

하지만 이를 스크린리더가 읽게 되면

오시는 길 언더바 언더바 그레이터댄 이라고 읽습니

따라서 불필요한 내용에 대해서 Screen Reader가 읽지 않도록 설정할 필요성이 있습니다.

이때 사용하는 것이 aria-hidden 속성입니다.

   <span
        className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"
        aria-hidden="true"
      >

 

이후 위의 연락처 -> 052-261- 5515를 Screen Reader에 읽게 하면

연락처 052 언더바 261 언더바 5515 버튼 이라고 읽게 됩니다.

 

(Children Presentational이라고 자식요소를 한꺼번에 읽은 후 자신의 역할을 설명하는 요소들이 있습니다.

이때 버튼도 해당 요소중 하나라서 위와 같이 읽습니다.)

https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion


#2. 연락처 버튼을 클릭시 번호가 클립보드에 저장되는 이를 시각장애자 입장에서 알기 힘들다 

function CardClipWrapper({ text, children }: CardClip) {
  const copylink = () => {
    navigator.clipboard.writeText(text);
    alert(text + '가 클립보드에 복사되었습니다');
  };
  return (
    <button
      onClick={copylink}
      className="flex flex-col items-start lg:text-left group rounded-lg border h-full px-5 py-4 transition-color border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30 w-full max-w-96"
    >
      <span className="sr-only">클릭시 번호가 클립보드에 저장됩니다</span>
      {children}
    </button>
  );
}

이를 위해 sr-only class를 활용하여 스크린 리더만 읽는 text를 추가하였습니다.

 

 

참고문헌

추가적인 학습을 하고 싶다면 FE 컴프 영상을 참고하거나, 

https://www.youtube.com/watch?v=tKj3xsXy9KM

 

http://www.websoul.co.kr/accessibility/WA_guide22.asp

 

웹 접근성 지침 2.2 | Web Soul Lab

웹 접근성 지침 2.2 한국형 웹 콘텐츠 접근성 지침 2.2은 4가지 원칙과 각 원칙을 준수하기 위한 14개 지침 및 해당 지침의 준수여부를 확인하기 위해 33개의 검사항목으로 구성되어 있습니다. ※ 20

www.websoul.co.kr

에서 내용을 확인해도 좋다고 생각합니다.

+ Recent posts