Coding Confetti on Canvas
Coding Confetti on Canvas
This is Confetti Simulator
Try to change settings
Click to Shoot
캔버스에 폭죽 효과 렌더링하기: TSParticles의 confetti demo 같은 것을 만들어보자.
이 글에서는 디테일한 코드는 생략하고 요구사항과 대략적인 구조만 설명한다. 각 코드셋은 설명에 필요한 부분만 따 왔으므로, 전체 코드는 여기를 참고!
단계 나누기
- 파티클 생성하기
- 렌더링하기
- 쏘아올리기
- 흔들림 묘사하기
파티클 생성하기
파티클 생성 시점 / 갯수를 바깥에서 조절할 수 있도록 외부로 노출한다. 여러개의 파티클을 추가하고 싶다면, 반복문을 돌면서 알아서 추가하는걸로 하자. 이렇게 해야 파티클 위치나 각도를 바꿔가면서 다양하게 그리는 유연성이 확보될 수 있을 것 같다.
interface Size {
width: number
height: number
}
interface Vector {
x: number
y: number
}
interface InitialParticleOptions {
size: Size
initialPosition: Vector // in 0-1, relative position on canvas [x, y]
initialSpeed: number
initialAngle: number // 0-360, speed + angle => velocity
}
interface Particle {
id: number
position: Vector
velocity: Vector
rotation: number
angularSpeed: number
size: Size // shape and size? 에이. 일단 사각형으로 그리자.
color: string
}
export function useConfetti(canvas: HTMLCanvasElement) {
const particles = useRef<{ [id: number]: Particle }>({}) // 이 형태로 있어야 파티클 데이터를 메모리에서 "지우기" 쉽다.
const particleIds = useRef<number[]>([]) // 루프를 돌면서 그리기 위해 필요하다
function addParticle(options: InitialParticleOptions) {
// set initial props from options,
// and some random initial props (color, rotation speed, etc.)
const particle = createParticle(options)
particleIds.current.push(particle.id)
particles.current[particle.id] = particle
}
return { addParticle }
}
렌더링하기
파티클이 추가되면 렌더링될 수 있도록 하자.
export function useConfetti(canvas: HTMLCanvasElement) {
// draw next frame
const draw = () => {
const ctx = canvas.getContext("2d")!
// draw on canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
particleIds.current.map((id) => {
const particle = particles.current[id]
drawParticle(ctx, particle)
})
}
function addParticle(options: InitialParticleOptions) {
...
// trigger animation start when a particle is added
draw()
}
}
// 사각형으로 그린다.
function drawParticle(ctx: CanvasRenderingContext2D, particle: Particle) {
const { x, y } = particle.position
const { width, height } = particle.size
ctx.fillStyle = particle.color
ctx.fillRect(x - width / 2, y - height / 2, width, height)
}
쏘아올리기
애니메이션이 필요해지는 시점이다. requestAnimationFrame
을 사용한다.
requestAnimationFrame이 필요할때만 동작하도록, active
상태를 관리하자.
애니메이션을 위해 중력 및 마찰계수 값을 설정 받도록 하자.
interface ConfettiOptions {
gravity: number // 파티클의 낙하 속도 결정
friction?: number // 공기저항이 없으면 점점 더 빠르게 떨어진다.
}
export function useConfetti(canvas: HTMLCanvasElement, confettiOptions: ConfettiOptions) {
const prevFrame = useRef<number>(0)
const drawHandle = useRef<number>(0)
const [active, setActive] = useState(false)
// start draw when active
useEffect(() => {
if (active) {
window.requestAnimationFrame((t) => (prevFrame.current = t))
drawHandle.current = window.requestAnimationFrame(draw)
} else {
window.cancelAnimationFrame(drawHandle.current)
}
}, [active])
// draw frame
const draw = (time: number) => {
const ctx = canvas.getContext("2d")!
// timing
const dt = time - prevFrame.current
prevFrame.current = time
// draw on canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
particleIds.current.map((id) => {
const particle = particles.current[id]
addMovement(particle, dt, confettiOptions)
drawParticle(ctx, particle)
})
// draw next frame
drawHandle.current = window.requestAnimationFrame(draw)
}
}
매 프레임마다 어느정도로 움직일 지를 결정할 addMovement 함수는 이렇게 생겼다.
리덕스에서 사용하던 것 처럼 object를 새로 만들지 않고 particle
오브젝트를 바로 변경한다.
- 이 방식이
{ ...prevObject, ...update }
방식보다 훨씬 빠르게 동작하며, - 어차피 다음 프레임이 되면 draw 함수에서 이 값들을 읽어 갈 것이기 때문이다.
리덕스에서 destructing and constructing을 했던 이유는 해당 값이 바뀐걸 리덕스가 알아야만, 재 랜더링이 일어나기 때문이었다는 점을 기억하자.
function addMovement(particle: Particle, dt: number, confettiOptions: ConfettiOptions) {
const { position, velocity } = particle
const { gravity, friction = 0 } = confettiOptions
const { x, y } = position
const { x: vx, y: vy } = velocity
/* position / velocity 중 어느 것을 먼저 계산하는지는 거시적으로는 거의 차이가 없다. */
// 움직임을 계산한다.
particle.position = { x: x + vx * (dt / 1000), y: y + vy * (dt / 1000) }
// 마찰에 의한 감속과 중력에 의한 가속을 더한다.
// 실제 공기저항 공식과는 차이가 있지만, 이 정도로도 나름 만족스러운 결과물이 나온다.
particle.velocity = { x: (1 - friction) * vx, y: (1 - friction) * (vy + gravity * (dt / 1000)) }
}
흔들림 묘사하기
이제 가장 어려운 부분이다. 폭죽 파티클이 흔들리는걸 대체 어떻게 묘사해야 할까? 앞서 소개했던 confetti demo 를 다시 살펴보자. 대략 어떤 축을 기준으로 rotate되는 것 같은 느낌과, 앞뒤로 팽그르르르 돌아가는 듯한 움직임이 눈에 띈다. rotate는 알겠는데, 돌아가는 움직임은 3D로 그린 것만 같다. 이걸 어떻게 캔버스에 그려넣지?
3D 물체를 캔버스에 그리는 방법 이라는 글에, 이런 움직임을 2D로 계산해내는 방법이 나와있다. 3D 공간에서는 회전축이 3개나 된다. pitch, yaw, roll 이라는 세 가지 회전이다. 그렇다면... 이 폭죽 파티클에 사용될 회전은 주로 pitch(백덤블링 하는 방향의 회전이다.)와 roll(옆구르기 방향의 회전)이다. (yaw는 김연아의 회전이다)
interface Particle {
...
rotation: [pitch: number, yaw: number, roll: number]
angularSpeed: [pitch: number, yaw: number, roll: number]
...
}
파티클의 회전 관련 항목을 셋으로 늘렸다. 그리고 회전을 적용했다.
function addRotation(particle: Particle) {
const [p, y, r] = particle.rotation
const [dp, dy, dr] = particle.angularSpeed
particle.rotation = [p + dp, y + dy, r + dr]
}
하지만 더 중요한건, 이 각도를 계산해 사각형을 사각형이 아니도록 그리는 방식이다.
function drawParticle(ctx: CanvasRenderingContext2D, particle: Particle) {
const [pitch, yaw, roll] = particle.rotation
const { width, height } = particle.size
ctx.transform(1, 0, 0, 1, particle.position.x, particle.position.y)
ctx.transform(1, 0, 0, Math.cos(pitch), 0, 0)
ctx.transform(Math.cos(yaw), 0, 0, 1, 0, 0)
ctx.transform(Math.cos(roll), -Math.sin(roll), Math.sin(roll), Math.cos(roll), 0, 0)
ctx.fillStyle = particle.color
ctx.fillRect(-width / 2, -height / 2, width, height)
ctx.resetTransform()
}
보너스
사실 여기까지만 하면 파티클이 너무 차분하게 떨어진다. 마치 바람 한 점 없는 날에 내리는 눈 같이, 조용한 수직 낙하를 한다. 하지만 실제로 (방송에서 쓰는) 꽃가루 같은걸 보면 바람 한 점 없어도 이리저리 흔들리며 떨어진다. 그 속사정에는 단순한 공기저항을 넘어서는 복잡다단한 유체역학이 숨어있을테지만, 조금만 단순하게 고민해보자.
그러니까... 떨어지는 파티클은, 좌우로 약간씩의 움직임을 보인다. 매 프레임마다, x 좌표를 좌우로 조금씩 움직여주자. 빠르게 떨어질수록 더 많은 흔들림을 보이는건 어떨까.
function addSwingMovement(particle: Particle, friction = 0) {
const { x, y } = particle.position
const { x: vx, y: vy } = particle.velocity
// do not swing during going up
if (vy < 0) return
const swingX = 0.05 * vy * friction
const swingY = 0.5 * vx * friction
particle.swing += 0.2
particle.position = { x: x + Math.cos(particle.swing) * swingX, y: y + Math.sin(particle.swing) * swingY }
}
그리고 마지막, 성능
파티클이 많아지면, 속도가 느려진다. 그러니, 화면에서 보이지 않게 된 파티클은 더이상 계산도 렌더링도 하지 않을 수 있도록 정리하자.
const draw = (time: number) => {
...
// remove invisible falling particles
const invisibleFalling = particleIds.current.filter((id) => {
const particle = particles.current[id]
const invisible = particle.position.y > canvas.height * 1.2
const falling = particle.velocity.y > 0
return invisible && falling
})
invisibleFalling.map((id) => delete particles.current[id])
particleIds.current = particleIds.current.filter((id) => !invisibleFalling.includes(id))
// if particles are all gone, inactivate animation
if (particleIds.current.length === 0) {
return setActive(false)
}
// else, draw next frame
...
}
예제
이 페이지 상단에 있는 예제는 아래와 같은 코드를 통해 파티클이 쏘아진다.
const confettiOptions = {
gravity: 1000,
friction: 0.04,
colorSet: ["red", "aqua", "orange", "deeppink", "greenyellow", "magenta", "yellow", "dodgerblue"],
}
const { addParticle, stop, resume } = useConfetti(canvas, confettiOptions, {
onStart: () => setPlaying(true),
onStop: () => setPlaying(false),
})
function handleClick() {
for (let i = 0; i < 30; i += 1) {
addParticle({
size: { width: 10, height: 10 },
initialPosition: { x: 0.5, y: 1 },
initialSpeed: randomRange(1300, 1600),
initialAngle: randomRange(-75, -105),
})
}
}