ALGORITHM

vanilla js ] Canvas에 적당히 사실적인 Sprite 그림자 그리기

김종상 2024. 4. 6. 15:28

서론


웹 2D 게임엔진을 개발 중, 좀 더 풍부한 시각적 요소를 추가하기 위해 그림자, 빛, 날씨 효과 등의 요소가 필요할 것으로 판단하여 새로운 이펙트효과를 개발하는 과정에서 배우고 적용한 내용들을 기록해 볼까 합니다.

제가 공부하고 이해했던 방식을 역추적해 작성되어 정보가 정확하지 않을 수 있습니다.

 

또한,

태양의 고도, 남중고도, 지구와의 거리, 날씨 등을 모두 고려하여 그림자를 표현하기에 js 를 기반으로 웹에서 실행시키는 계산량이 너무 많을 것 같아 모든 변수를 감안해 시뮬레이션하기 보다는 적당히 사실 적으로만 보이면 될 듯 싶어 태양의 고도, 객체의 크기만을 고려하여 적용했습니다.

 


본론


 

0~360° 까지의 sin 함수를 살펴보면

0~180° 까지 양수,

180~360° 까지 음수를 반환 합니다.

다시, 그림자를 생각해보면

태양이 90° 에  뜨는 시간에 그림자는 가장 짧아지고,

짧아진 길이만큼 그림자는 짙어집니다.

그래서 sin 값을 그림자의 길이투명도에 적용하고,

sin 값양수일 때는 을, 음수일 때는 이라고 가정하여

에만 오브젝트의 그림자를 그리고, 에는 전체 면적을 어둡게 하였습니다.

 

이제 테스트 코드를 작성해봅니다.

 

처음부터 스프라이트에 따른 그림자를 그리기 보다는 사각형 도형을 통하여 그림자를 표현해 수식을 완성해 보았습니다.

 

RESULT

 

 

HTML

<body>
<canvas style="width: 100vw; height: 100vh; background-color: white;"></canvas>
</body>

 

 

JS

	const pi = Math.PI
        const canv = document.querySelector("canvas")
        const ctx = canv.getContext("2d",{ willReadFrequently: true })
        const dpr = window.devicePixelRatio


        const canvasWidth = window.innerWidth
        const canvasHeight = window.innerHeight

        canv.style.width = canvasWidth + 'px'
        canv.style.height = canvasHeight + 'px'
        canv.width = canvasWidth*dpr
        canv.height = canvasHeight*dpr
        
        // 자연수° 를 넣으면 radian 으로 반환
        /* 
        우리가 쓰기 편한 것은 자연수° 지만,
         삼각함수 계산 시에는 라디안으로 계산해야 합니다. 
        */
        function getDegree(deg) {
            return deg * pi/180
        }

        function GlobalLight(a){            
            // angle
            this.a = a
            // brightness
            this.b = 1

            this.dropShadow = () => {
                const sin = Math.sin(getDegree(this.a))
                const cos = Math.cos(getDegree(this.a))
                for(const p of rList){
                    // 그림자의 밝기를 구합니다.
                    /*
                    앞서 본문에서 언급했듯 그림자의 길이가 짧아질 수록 강도가 강해지고,
                    길어질 수록 강도가 약해져야해서 sin 값을 곱해주었습니다.
                    */
                    ctx.fillStyle = `rgba(0,0,0,${0.4 * Math.sin(getDegree(this.a))})`
                    // 그림자를 그립니다.
                    ctx.beginPath()
                    // 그림자의 시작 위치는 피사체의 바닥이므로 피사체의 좌측 하단 에서 출발합니다.
                    ctx.moveTo(p.x, p.y + p.height)                    
                    // 좌측 하단 -> 우측 하단
                    ctx.lineTo(p.x + p.width, p.y + p.height)
                    // 우측하단 -> 
                    // 단위원에서의 각도에 따른 좌표 값 (x, y) -> (cos(Θ), sin(Θ)) 이므로
                    // (cos(Θ) * 높이, sin(Θ) * 높이)에 라인을 긋습니다.
                    ctx.lineTo(p.x + p.width + p.height * cos, p.y + p.height + p.height*sin)
                    // - 너비
                    ctx.lineTo(p.x + p.height * cos, p.y + p.height + p.height*sin)
                    // 다시 원점으로 복귀 후 채워줍니다.
                    ctx.lineTo(p.x, p.y + p.height)
                    ctx.fill()                    
                }
            }
        }
        function Rect (x, y, width, height){
            this.x = x
            this.y = y
            this.width = width
            this.height = height

            this.draw = () => {
                ctx.fillStyle = "rgba(200,200,120,1)"
                ctx.fillRect(this.x, this.y, this.width, this.height)
                ctx.fillStyle = "Black"
            }
        }
        const globalLight = new GlobalLight(0)
        const rList = []
        rList.push(new Rect(50,50,50,50))
        rList.push(new Rect(50,150,75,50))
        rList.push(new Rect(50,250,50,75))

        // 16ms 마다 호출 될 함수
        const repeatOften = () => {
            const st = new Date()
            ctx.clearRect(0,0, canvasWidth,canvasHeight)
            globalLight.a+=0.5
            if(globalLight.a > 360){
                globalLight.a = 0
            }
            globalLight.dropShadow()
            for(const p of rList){
                p.draw()
            }

            // -(밤 시간의 최대 어둡기) * sin(Θ) 만 하면 낮과 밤 사이 끊기는 느낌이 있어 자연스러운 연출을 위해 sin 값에 -0.3을 해주었습니다.
            globalLight.b = -0.5 * (Math.sin(getDegree(globalLight.a)) - 0.3)
            ctx.fillStyle = `rgba(0,0,0,${globalLight.b})`
            ctx.fillRect(0,0,canvasWidth,canvasHeight)
            const et = new Date()
            console.log(`${et - st}ms required`)
            setTimeout(repeatOften, 16 - (et - st));
        }
        repeatOften()

 


위를 바탕으로 sprite에 적용한 내용은 아래와 같습니다.


 

RESULT -> 03:00

 

 

JS

function character(img){
        this.src = img
        this.x = 32
        this.y = 32
        this.height = this.src.height
        this.width =this.src.width        
        this.init = () => {
            this.height = this.src.height
            this.width =this.src.width
        }
        this.draw = () => {
            ctx.drawImage(this.src,this.x, this.y, this.width, this.height)
        }
        this.dropShadow = () => {
            const sin = Math.sin(getDegree(gl.a))
            const cos = Math.cos(getDegree(gl.a))
            const absCos = Math.abs(cos)
            let imgWidth = this.src.width
            let imgHeigth = this.src.height
            let sX = this.x            
            let sY = this.y + imgHeigth
            ctx.fillStyle = `rgba(0,0,0,${0.7 *(-1 * sin)})`
            ctx.fillRect(0,0,rect.width,rect.height)
            if(gl.a > 180) return
            ctx.fillStyle = `rgba(0,0,0,${0.5 * sin})`
            ctx.beginPath()
            let pStarted = false
            let positionsArr = []
            for(i = imgHeigth-1; i >= 0; --i){
                const tmpPositions = []
                let flag = false
                for(k = imgWidth-1; k >= 0; --k){
                    const iData = ctx.getImageData(this.x + k, this.y + i, 1, 1).data
                    // if exist color
                    // console.log(iData)
                    const tmpX = sX + imgWidth - k-1 + (imgWidth - i)*cos*2 + cos
                    const tmpY = sY + (imgHeigth - i)*(sin) -sin
                    if(iData[3] > 0 || iData[2] > 0 || iData[1] > 0 || iData[0] > 0){
                        if(!pStarted){
                            pStarted = true
                        }
                        if(!flag){
                            flag = true
                            tmpPositions.push({
                                x: tmpX,
                                y: tmpY})
                            // ctx.lineTo(
                            //     sX + imgWidth - k + (imgHeigth - i)*cos,
                            //     sY + (imgHeigth - i)*sin
                            // )
                        }
                        if(flag && tmpPositions.length >= 1){
                            tmpPositions[1] = {
                                x: tmpX,
                                y: tmpY}
                            positionsArr.push(tmpPositions)
                        }
                    }
                    else {
                        if(flag && tmpPositions.length == 1){
                            tmpPositions.push({
                                x: tmpX,
                                y: tmpY})
                            positionsArr.push(tmpPositions)
                        }
                    }
                }
            }
            ctx.beginPath()
            for(i = 0; i < positionsArr.length; ++i){
                ctx.lineTo(positionsArr[i][0].x-1,positionsArr[i][0].y)
            }
            for(i = positionsArr.length-1; i >= 0; --i){
                ctx.lineTo(positionsArr[i][1].x-1,positionsArr[i][1].y)
            }
            ctx.fill()
            ctx.fillStyle = "rgba(0,0,0,0.25)"
            // ctx.beginPath()
            // ctx.moveTo(this.x,this.y + this.height)
            // ctx.lineTo(this.x + this.width ,this.y + this.height)
            // ctx.lineTo(this.x + this.width +this.width*cos ,this.y + this.height + this.height * sin)
            // ctx.lineTo(this.x + this.width *cos,this.y + this.height + this.height * sin)
            // ctx.lineTo(this.x ,this.y + this.height)
            // ctx.fill()
        }
    }
    function repeatOften () {
        const st = new Date()
        ctx.clearRect(0,0,rect.width,rect.height)        
        gl.a+=0.5
        if(gl.a > 180){
            gl.a = 0
        }
        c.draw()
        c.dropShadow()
        const et = new Date()
        console.log(`${et - st}ms required`)
        setTimeout(repeatOften,16);
    }

 


결론


  • 시간 복잡도가 높아 최적화를 많이 거쳐야할 것 같습니다...
  • 위 소스코드는 img src의 각 행의 시작 점과 끝 점을 기록 후 렌더링 하기 때문에 중간이 비어있는 개체는 올바르게 렌더링하지 못합니다.