서비스 링크 : https://snowy-winter-wonderland.vercel.app/
개발 레포지토리 링크 : https://github.com/naro-Kim/snowy-window위 url을 통해 프로젝트를 체험하고 살펴보실 수 있어요.
Winter wonderland는 three.js
기반의 react-three-fiber
와 Next.js
프레임워크로 제작한 프로젝트입니다. 평소 좋아하던 3d로 개인 작업을 하고 싶었고 올 한해 배웠던 프론트엔드 지식들과 three.js 지식들을 최종 복습하고자 이번 항해 코육대를 기회로 시도했어요.
이런 목적에 맞게, 프로젝트 구현에 있어 가장 신경썼던 부분들은 성능 최적화
였어요. 이전에 여러 3d web 프로젝트들을 체험했을 때 사용자 경험 상 성능의 문제가 발생하면 페이지 컨텐츠를 다 보기도 전에 이탈하게 되더라구요. 이런 문제를 해결하기 위해 도입했던 아이디어는 3d asset preload 부분이었어요.
- three.js 프로젝트에서 항상 문제가 되는 것은 성능 최적화 부분! 따라서, 이번 프로젝트에서 Lighthouse 성능지표를 최대로 끌어올려보자
- gltf to tsx convert로 로딩 시간을 최소화하자
- 불필요한 texture, polygon, mesh수를 줄여 용량을 최소화하자
최적화를 목표로 하면서, 다음으로 '눈닦기'라는 이번 주제에 맞춰 프로젝트 기능을 기획했어요.
프로젝트의 모티브가 되어준 겨울 풍경
눈이 오는 겨울, 집에 있을 때 창밖을 멍하니 보던 경험을 기억하시나요? 저는 가끔 눈이 많이 오는 날이면 유튜브에서 winter jazz 24/7
과 같은 검색어로 음악을 켜두고 작업을 하는데요, 이런 경험에서 떠오른 기능들로 프로젝트를 기획했어요.
- 눈이 계속 쌓여 하얀 화면을 구현합니다.
: 프로젝트에 접속한 사용자의 뷰를 하나의 '창'으로 생각했어요. 창밖을 바라보는 경험을 떠올리고, 창틀 너머로 눈송이가 내리는 풍경을 기획했어요.
- 마우스 커서/터치에 따라 하얗게 쌓인 눈이 닦이는 웹/앱을 구현하세요.
: 창가에 쌓여있는 눈뭉치들을 밀어내는 인터랙션을 구현했어요. 눈을 터치하면 눈뭉치가 밀려나고, 빠작빠작거리는 소리가 나요. 다만, 현실의 경험에서 비롯해 창 한가득 내린 눈 대신 어느정도 쌓여있는 눈을 구현했어요.
- 닦인 화면에도 눈이 쌓이고, 다시 하얗게 변합니다.
: 시간이 지나면 눈이 쌓여요. 풍경을 넉넉히 감상하기 위해 다시 눈이 쌓이는 시간은 1분 20초 정도로 기획했어요. 눈을 치우고 풍경을 보다가 하루 일을 하고 다시 돌아보면 어느새 눈이 다시 쌓여있곤 했거든요.
위에서 기획한 부분을 토대로, 크게 아래처럼 구현 요구사항 리스트를 계획해서 프로젝트를 진행했어요.
- 기술 스택 선택
- 3d 에셋 제작
- 눈송이 시뮬레이션 구현
- 눈 쌓기 & 치우기 시뮬레이션 구현
- 기타 인터랙션 구현
프로젝트 기간이 일주일 남짓인 만큼, 기술 스택은 가장 익숙하고 활용하기 편한 기술들을 선택했어요. 그러면서도 최적의 성능을 만드려고 노력했어요.
- 💡
Nextjs
: canvas 요소를 제외한 컴포넌트들의 SSG를 위해 (추후 방명록 등 인터랙션과 관련한 추가 기능들이 있을 수 있으므로..)React
: canvas element는 Client side rendering이 필요하기 때문에 'use client'로 CSR 방식의 리액트 컴포넌트들을 사용했어요.React-three-fiber
→ useFrame, useThree와 같은 간편한 훅으로 scene control의 편리성과 가독성을 높이기 위해 선택했어요. 덕분에 눈을 치우거나 눈사람을 클릭하는 object interaction을 구현할 수 있었어요.Vercel
: 레포지토리 기반의 편리한 프론트엔드 배포로 빠른 시간안에 구현된 페이지를 보여주는데 적합하다 생각했어요
귀엽게 잘 만들었나요?
기술을 선정한 이후엔, 빠르게 3d씬을 만들었어요. 평소 사용하던 가볍고 빠른(c4d, rhino등에 비해) 3d툴인 블렌더를 사용했습니다. 창밖의 겨울 풍경을 그리는 만큼 크리스마스는 지나가지만 겨울스러운 눈사람과 트리를 준비했어요. 가능한한 키 비주얼이 될 에셋들(특히 눈사람)은 직접 만들고, 이외의 환경을 구성할 눈 쌓인 나무 에셋들은 itch.io에서 무료 에셋들을 가져와서 썼어요.
web에서 사용할 3d 에셋 제작할 때 신경써야할 점들은, blender의 렌더링 환경과 three.js의 렌더링 환경이 완전히 다르다는 점이에요. 자세한 건 관련 포럼 글을 참조해주세요! 이런 차이에 의해 초반에는 모든 텍스쳐들을 cycle 렌더러에서 bake하고 three.js로 넘어갔었어요.
만들어진 모델을 export 할 땐, glb+bin으로 나누어 추출하는 옵션을 선택했어요. 이 방식으로 추출한 모델은 gltf embeded 방식보다 텍스쳐 해상도 최적화 작업 후 용량이 훨씬 줄어들거든요.
위 이미지는 glb+bin으로 추출한 snow scene asset을 텍스쳐 해상도 최적화 후 합친 gltf 용량과 비교한 사진이에요. 약 81.75%가 감소한 걸 확인할 수 있죠. 이렇게 에셋 최적화가 하나씩 이루어지면, 인터랙티브 웹을 이루는 경험이 개선될 수 있어요!
이 과정에서 유용하게 이용했던 사이트는 2가지가 있어요. 하나는 gltf 압축 사이트인 gltf.report이고 다른 한 가지는 gltf -> react three fiber converter에요. 이 두 가지 사이트 모두 react-three-fiber, react-drei 등을 만든 poimndres 소속의 Don McCurdy가 만들었다는 점에 안정성을 믿고 선택했어요.
요약하면 에셋 디자인 > 텍스쳐링 > gltf 압축 > gltf to tsx convert
의 과정으로 모델을 준비했어요.
그리고, 에셋을 화면에 띄우고 window 사이즈에 맞춰 canvas resizing을 진행해요. 화면비에 알맞게 asset의 중심점이 위치할 지점을 window.innerWidth/window.innerHeight + 1.5
의 거리로 구했어요.
scene에 올릴 assets들이 어느정도 준비된 다음, 구현한 기능은 눈송이 시뮬레이션이었어요. 눈이 내리는 겨울 풍경을 구현할 방법을 가장 먼저 생각해봤어요.
두번째 방법을 채택하고, 기존에 unity나 three.js로 제작된 참고 자료들을 바탕으로 react-three-fiber의 particle system을 작성했어요!
처음엔 vector를 어떻게 이동시켜야하는지 감이 잡히지 않아 어려움을 겪었어요. 아래는 초기의 vector 이동 코드에요.
useFrame((_, dt) => {
const posArr = positionRef.current.array;
const velArr = velocityRef.current.array;
for (let i = 0; i < posArr.length; i += 3) {
const x = i;
const y = i + 1;
const z = i + 2;
// x축은 양옆으로 움직여야 한다.
const velX = Math.sin(dt * 0.001 * velArr[x]) * 0.1;
// z축은 앞뒤로 움직여야 한다.
const velZ = Math.cos(dt * 0.0015 * velArr[z]) * 0.1;
posArr[x] += velX;
posArr[y] += velArr[y];
posArr[z] += velZ;
if (posArr[y] < -minRange) {
posArr[y] = minRange;
}
}
positionRef.current.needsUpdate = true;
velocityRef.current.needsUpdate = true;
});
눈송이가 예상한 것과 다르게 y축 이동 값보다 z축 이동값이 너무나도 컸는지 앞으로 마구 달려들어, 참고 자료와 다르게 프로젝트의 카메라 방향이나, 세팅에 맞춰 이동 방향을 바꾸어야했어요.
따라서 z축 이동을 일단 없앴어요. 완벽하다 할 순 없지만, 이번 3d scene을 기준으로 꽤 적절한 시뮬레이션을 보여줬어요. 이 과정에서 시뮬레이션의 값을 각자의 프로젝트에서 구현되길 원하는 비쥬얼과 방향성에 맞춰 조절해야 함을 느꼈어요. 약간의 주석과 함께 코드는 아래와 같이 썼어요.
import * as THREE from 'three';
import { useMemo, useRef } from 'react';
import { useFrame, useLoader, useThree } from '@react-three/fiber';
const SnowInstances = ({ count = 200, velocity = 0.01 }) => {
const particles = useRef<THREE.Points>(null!);
const positionRef = useRef<THREE.BufferAttribute>(null!);
const velocityRef = useRef<THREE.BufferAttribute>(null!);
const [minRange, maxRange] = useMemo(() => [-8, 8], []);
// count개수만큼의 눈송이가 가질 위치 벡터 정보를 담은 array
const points = useMemo(() => {
const p = new Array(count)
.fill(0)
.map((v) => (0.5 - Math.random()) * maxRange);
return new THREE.BufferAttribute(new Float32Array(p), 3);
}, [count]);
// count개수만큼의 눈송이가 가질 속력 벡터 정보 array
const velocities = useMemo(() => {
const v = new Array(count * 3)
.fill(0)
.map(() => (Math.random() - 0.5) * 0.1);
return new THREE.BufferAttribute(new Float32Array(v), 3);
}, [count]);
// textureLoader로 webp 이미지를 받아 눈송이 텍스쳐에 적용했어요.
const flakeMaterial = useMemo(() => {
const snowflakeMap = useLoader(THREE.TextureLoader, '/assets/snowflake.webp');
const mat = {
size: 0.2,
color: 0xffffff,
vertexColors: false,
map: snowflakeMap,
transparent: true,
fog: true,
depthWrite: false,
};
return mat;
}, []);
//useFrame 훅을 통해, 매 프레임마다 이동하는 눈송이를 구현합니다.
useFrame((_, dt) => {
const posArr = positionRef.current.array;
const velArr = velocityRef.current.array;
// 창밖의 표현 구현과 동시에 최적화를 위해 z축 이동은 멈추어두었어요.
// 의도에 따라, velocity 변경은 없앴어요.
for (let i = 0; i < posArr.length; i += 3) {
const x = i;
const y = i + 1;
const velX = Math.sin(dt * velArr[x] * (i < 4 ? i + 1 : -(i + 1))) * 0.01;
let velY = Math.cos(dt * 0.01 * velArr[y]) * velocity;
posArr[x] += velX;
posArr[y] -= velY; //눈이 내리는 위치 조정
// recycle snow
// 지정된 범위를 지나친 눈송이는 다시 시야에 보이는 각도에 렌더링해요.
if (posArr[y] < minRange) {
posArr[y] = (1 - Math.random()) * maxRange * 0.01;
velY = 0;
}
if (posArr[x] > maxRange || posArr[x] < minRange) {
posArr[x] = (1 - Math.random()) * maxRange * 0.01;
}
}
positionRef.current.needsUpdate = true;
velocityRef.current.needsUpdate = true;
});
// 눈송이는 maxRange 위치에서 내려오기 시작해요
return (
<group position={[0, maxRange, 0]}>
<points ref={particles}>
<bufferGeometry>
<bufferAttribute
ref={positionRef}
attach="attributes-position"
{...points}
/>
<bufferAttribute
ref={velocityRef}
attach="attributes-velocity"
{...velocities}
/>
</bufferGeometry>
<pointsMaterial {...flakeMaterial} sizeAttenuation={true} />
</points>
</group>
);
};
export default SnowInstances;
여기까지 작성한 눈송이 시뮬레이션을 시각적으로 확인해보았어요.
OrbitControl
을 통해 씬을 돌려보니, 전체 씬이 심심했어요. 아무래도 눈이 더 쌓여있는 모습이 좋을 것 같아 텍스쳐 수정과 함께 몇 가지 오브젝트를 추가하기로 결정했어요. 그리고 눈송이가 날리는게 더 잘보이도록 Size도 키웠습니다.
참고자료
눈 내리는 시뮬레이션을 구현한 다음엔, 내린 눈이 쌓이는 모습을 구현해야 했어요. 그리고 눈이 쌓인 다음엔 그걸 닦고 치우는 인터랙션도 가능해야 했기 때문에 이를 고려해 크게 두가지 접근 방법을 생각했어요.
1번 방법으로 접근해서 쌓인 눈을 구현하다가, react-three-fiber 내부의 구현이 glsl 사용과 혼용하기엔 복잡하단 것을 깨달았어요. 애초에 three.js 대신 react-three-fiber를 사용한 이유도 함수형 컴포넌트로 통일된 코드를 작성하기 위함이었거든요. react-three-fiber에 glsl을 사용하는 순간, fragment와 vertex가 하드코딩으로 노출되면서 코드의 가독성이 떨어졌어요. displacement map 이미지를 씌우는데에만 무려 25줄이 쓰이고 여기에 클릭 인터랙션에 따라 더욱 복잡한 로직 추가가 필요했어요.
//생략
...
const points = useMemo(() => {
...
}, [count]);
const flakeMaterial = useMemo(() => {
...
}, []);
const shader = {
uniforms: {
texture: { value: new THREE.TextureLoader().load("/assets/snowDisplacementMap.webp") },
},
vertexShader: `
attribute vec3 position;
attribute vec3 offset;
uniform float time;
varying vec2 vUv;
void main() {
vec3 newPosition = position + offset;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
vUv = uv;
}
`,
fragmentShader: `
uniform sampler2D texture;
varying vec2 vUv;
void main() {
gl_FragColor = texture2D(texture, vUv);
}
`,
};
useFrame((_, dt) => {
...
}
...
//후략
동시에 Noise 형태의 이미지를 구해서, glsl 텍스쳐의 displacement map으로 사용하고자 했어요. 이를 planeBufferGeometry에 씌우려 하자 react-three-fiber에 planeBufferGeometry가 정의되어 있지 않다는 에러와 함께 extend해달란 경고가 떴어요. 하지만 이전 프로젝트에서 planeBufferGeometry를 별도의 extend없이 호환해 사용했었던 경험이 있었고 ts defined docs에서도 문제점을 찾지 못해, 2시간 정도 헤매이고 2번 방법으로 방향성을 바꾸었어요.
바꾼 코드는 아래와 같습니다.
//SnowAccumulation.tsx
/**
* @params {Three.Vector3} position : 눈더미가 렌더링될 위치입니다.
* @params {number} count : 렌더링될 눈더미의 개수입니다.
*/
const SnowAccumulation = ({ count = 20, position }: AccumulationProps) => {
const ref = useRef<any>(null!);
const vec = useMemo(() => new THREE.Vector3(), []);
useFrame((gl, _dt) => {
// 시간이 지나면 눈이 쌓입니다. 화면을 뒤덮을 정도로 쌓이지 않도록 elapsedTime으로 조절합니다.
if (gl.clock.elapsedTime % 100 <= 0.02 && gl.clock.elapsedTime < 1000) {
const curPos = ref.current.position.clone();
ref.current.position.lerp(vec.set(curPos.x, curPos.y + 0.2, curPos.z),
0.05);
}
});
const snowEffectSound = new Audio(snowSound);
const points = useMemo(() => {
const p = [];
// 랜덤한 위치에 눈더미가 렌더링됩니다.
for (let i = 0; i < count; i++) {
const x = (0.5 - Math.random()) * 3;
const y = (0.5 - Math.random()) * 0.1;
const z = (0.5 - Math.random()) * 0.01;
p.push(new THREE.Vector3(x, y, z));
}
return p;
}, [count]);
// 터치 혹은 클릭 시 인터랙션 구현
const handlePointEnter = useCallback((e: any) => {
e.stopPropagation();
// 눈을 0.1씩 깎아 내림
const t = e.eventObject.position.clone();
e.eventObject.position.y = MathUtils.lerp(t.y, t.y - 0.2, 0.2);
// 눈 치우는 소리 재생
snowEffectSound.play();
}, []);
return (
<Instances ref={ref} position={position} limit={count} range={count}>
<boxGeometry />
{points.map((pt, i) => (
<SnowBlock
scale={0.3}
onPointerDown={(e) => handlePointEnter(e)}
key={i}
position={pt}
/>
))}
</Instances>
);
};
export default SnowAccumulation;
여기서 더 나아가려면 onPointerDown 이벤트 발생시 e.eventTarget 내부에 event가 일어난 pointer 좌표도 함께 나타나요. 그렇다면 접점과 오브젝트 중점 사이 distance를 구할 수 있으므로 normalized vector position을 clone하면 들어온 포인터 방향에 밀려나간 오브젝트를 구현할 수 있어요. 하지만 제가 구현하던 과정 중에는 vector 뺄셈 계산시 결과에 NaN이 나타나 시간 상 스킵했습니다.
결과적으로, 고민했던 시간보다 훨씬 빠르게 창가 랜덤 좌표에 instance 내부 Box Geometry를 렌더링해서 쌓인 눈을 구현할 수 있었습니다. 브라우저 크기에 따라, pc 버전에선 창문틀에 쌓인 눈이 보이고 mobile 버전에선 시야에 바로 눈이 보여요. 원하는 효과를 구현해내며, 다음에 glsl에 다시 도전하기로 기약했어요!
여기까지 구현해도 충분히 마음에 들고 멋진 Scene이었지만, 테스트해보니 심심한 느낌이 들었어요. 그리고 주변 친구들로부터도 선물 상자나 눈사람과 상호작용하고 싶다는 피드백도 들었어요.
여기서 그만둘까?도 했지만 이때가 12월 26일로 마감일자까지 시간 여유가 조금 남아있었어요. 그래서 친구들의 요청에 따라 새로 방명록 기능과 .. 선물 인터랙션을 조합해보기로 합니다.
그리하여 만든 선물 방명록!
급하게 기획했지만, 떠오른 기능은 명확했어요. 사용자들이 들어와서 맵을 둘러보고 음악도 듣고 눈도 치우고 스노우맨도 눌러보다 선물을 누르면 확대되고, 사용자의 선물인 방명록을 작성할 수 있도록 했어요. 그리고, 개발 과정을 보여줄 수 있도록 개발 깃허브로 바로가는 버튼도 두었어요.
선물을 클릭해서 나오는 메세지는 이전에 활용했던 Geometry onPointerUp 이벤트와 dialog tag를 활용했어요. DB는 supabase를 활용해서, 간편하게 연동해두었고 정상적으로 comment가 submit되는 것을 확인했어요.
'use client';
import { insertData, supabase } from "@/api/client"; // supabase를 활용한 data insert api
import { useSceneContext } from "@/context/SceneContext"; // Scene 전체에서 현재 선물이 선택되어 있는지 판단하는 contenxtAPI.
import Link from "next/link";
import { useCallback, useRef, } from "react";
export const GuideMessage = () => {
const { zoom, setZoom } = useSceneContext() as any;
// Scene전체에서 하나의 물체에만 zoom이 잡히도록 ContextAPI를 활용했어요.
const handleBackButton = useCallback((e: any) => {
e.stopPropagation();
setZoom(false);
}, []);
const handleSubmit = useCallback(async (e: any) => {
e.preventDefault();
e.stopPropagation();
try {
// 간단하게, form에 입력한 내용을 바로 insert해요.
insertData({ table: 'comments', name: e.target.name.value, content: e.target.content.value });
setZoom(false);
} catch (error) {
console.log('Error occurred', { error })
}
}, []);
return (
<>
//context와 연동해 dialog show를 컨트롤해요.
<dialog open={zoom} className={`w-full sm:w-1/2 max-w-7xl rounded-xl bg-[rgba(0,0,0,0.5)] z-10 absolute bottom-1/4 left-1/2 -translate-x-1/2 translate-y-1/2 duration-500`}>
<div className={"text-white p-4 sm:p-8 grid grid-flow-rows gap-2"}>
<div className="grid grid-flow-col mb-2">
<h1 className={"font-semibold text-md sm:text-lg"}>Leave a Comment!</h1>
<button className="justify-self-end text-xs max-w-[160px] bg-gray-500/25 p-2 rounded-lg"><Link href={`https://github.com/naro-Kim/snowy-window`}>개발 깃허브 바로가기</Link></button>
</div>
<span className={"font-light leading-relaxed text-pretty text-xs sm:text-sm"}>
<p>반가워요! 새해를 맞이하는 마음으로, 일주일 간 개발한 react-three-fiber 프로젝트입니다. 프로젝트를 응원하는 메세지를 남겨주시면 큰 힘이 됩니다!</p>
</span>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<label className="text-right text-md" htmlFor="name">
Name
</label>
<input autoFocus required id="name" name="name" className="rounded-lg p-2 bg-[rgba(0,0,0,0.2)] col-span-3" placeholder="Enter your name" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<label className="text-right text-md" htmlFor="message">
Comment
</label>
<textarea required id="content" name="content" className="rounded-lg p-2 bg-[rgba(0,0,0,0.2)] col-span-3 min-h-[100px]" placeholder="Type your comment here" />
</div>
<div className="text-xs sm:text-sm grid grid-flow-col justify-self-end w-1/2 gap-2">
<button type="submit" className={'rounded-lg bg-blue-500 py-2 px-4'}>Submit Comment</button>
<button onPointerUp={handleBackButton} className={'text-gray-400/50 rounded-lg border-2 px-4 py-2 border-gray-400/50'} onClick={undefined}>
Close
</button>
</div>
</form>
</div>
</dialog>
</>
)
};
이렇게 작성한 dialog는 zoom context와 함께, 선물 외부를 클릭하거나 다른 물체와 상호작용시 자동으로 닫히게 돼요. onPointerUp에 showModal()을 부여하는 것도 작성해 보았지만, 무슨 이유에서인지 pointer event가 일어나지 않아 open value에 zoom을 주는 방법으로 대체했어요.
이전까지 방명록 기능은 백엔드에서 api를 받아와 사용해본 적만 있는데요, 이번에는 개인 프로젝트를 진행하며 supabase client와 관련한 튜토리얼을 빠르게 배워 적용했어요. 공식 문서의 사용 튜토리얼을 따르면 api, db table 설계까지 빠르게 진행 가능해서 아래와 같이 빠른 방명록 기능 테스트가 가능했어요.
이렇게 해서 완료한 프로젝트 배포 링크는 https://snowy-winter-wonderland.vercel.app/ 여기에요! 언제든지 피드백은 대환영입니다. 이번 프로젝트로, 올 한해 배웠던 three.js와 react-three-fiber 활용법을 총복습할 수 있었어요. 특히 협업 스터디에서 아쉬웠던 메시 최적화 부분에서, 직접 에셋을 제작해 원하는 만큼 polygon을 최적화할 수 있어 만족스러웠습니다. 그리고 이런 노력의 결과 덕분인지 3d임에도 chrome Lighthouse에서 꽤 괜찮은 성능 점수를 받을 수 있었어요.
위는 pc버전에서의 chrome lighthouse 점수에요. 그리고, opengraph를 활용해 웹페이지의 한 장면을 성공적으로 미리보기에 넣을 수 있었습니다.
다만 아쉬운건 여전히 Next.js의 Metadata
부분의 활용이 아직 이해하기 어렵다는 점이었어요. 분명히 opengraph를 작성하며 import type { Metadata } from 'next';
를 통해 layout에서 metadata의 description을 넣어주고 있는데, SEO 점수를 보면 알 수 있듯 description을 인식하지 못하고 있어요. 이 점은 차후에 더더욱 개선해나갈 예정이에요.
2024/01/11 업데이트) 도움을 받아 description issue를 해결해 SEO점수 100점을 달성했어요! 이번 이슈를 통해 og의 description은 소셜미디어를 위한 역할이고, meta 태그의 description은 search engine을 위한 역할을 수행한다는 점을 확실히 구분하게 되었어요.
총 정리하면 이번에 얻은 경험은 아래와 같아요.
- React-three-fiber 기반의 3d 인터랙티브 웹 개발
- audio, dialog등의 HTML 태그 기반 컴포넌트 활용
- Canvas interaction과 UI rendering의 융합
- Three.js에서 vector position movement 구현
- 프로젝트 시작에서 시작해 끝까지 기록하며 회고록 작성
- og와 meta 태그의 description은 각기 다른 역할임을 깨달음 (+ 2024/01/11)
개인적으로 아쉬웠던 점이나, 추후 개선할 점은 다음과 같아요.
- any대신 3d event type generic을 사용하기
- 유저가 보낸 comment를 3d instance에 map으로 렌더링하기
이전까지 프로젝트 시작에서 끝까지 세세히 기록해나갔던 경험은 좀처럼 없는데 이번 항해 코육대 '눈닦기' 주제에 프로젝트를 출품하며 새로운 도전을 할 수 있었어요. 앞으로도 3D 웹 프로젝트들과 관련해 해외 글들을 번역해가며 블로그에 계속해서 공유해가려 해요.
여기까지 글을 읽어주셔서 감사하고, 코드나 프로젝트와 관련된 피드백은 항상 환영하고 있습니다.
다들 2024년에도 행복하고 즐거운 한 해 되세요! :)
https://snowy-winter-wonderland.vercel.app/
https://github.com/naro-Kim/snowy-window
감사하게도, 좋은 평가를 받아 항해+ 제2회 코육대 눈닦기 부문 최우수상을 수상하게 되었습니다.
Thank you for sharing this content. It is very much valuable and such a skillful technique to learn. https://yangkhor.com/services
같은 대회 참가한 사람입니다~
너무 예쁘게 잘만드셨어요! 엄청 많이 배우고가요!