Inertia Tensor(2)

물리 2012. 2. 19. 17:19 Posted by 알 수 없는 사용자
이번에는 이전 운동량, 각운동량, 질량중심의 내용을 바탕으로 Inertia Tensor에 대해 설명 드리겠습니다.

1) 힘F(Input)에 대한 질점(Particle)의 변화량(Output)
아래의 그림처럼 m1에 F라는 힘을 가하면,  m1의 운동량(각운동량)이 변하게 됩니다.
 

 

 

식으로 나타내면 아래와 같고, 결과적으로 가속도가 변하게 됩니다.

 



2) 힘F(Input)에 대한 물체(RigidBody)의 변화량(Output)
아래의 그림은 m1과 m2가 붙어있은 경우 입니다.
힘 F를 가하면, 붙어있기 때문에 m1과 m2의 운동량(각운동량)이 모두 변하게 된다.

  

식으로 나타내면 아래와 같습니다.



v2는 상대운동을 함으로, 식을 아래와 같이 바꿀 수 있습니다.


그러나 역시, 식은 하나인데 변수는 2개입니다.  이식으론  m1, m2의 변화량을 구할 수 없습니다.
질점(Particle)과 달리 물체(RigidBody)의 변화량은,  운동량 보존법칙 하나만으론 해석할 수 없습니다.

다음은 각운동량 법칙을 이용해서 m1, m2의 변화량을 해석해 보겠습니다.
아래는 p점을 기준으로 각운동량의 변화량을 측정한 그림입니다. 


식을로 나타내면, 아래와 같습니다.



v2는 상대운동을 함으로, 식을 아래와 같이 바꿀 수 있습니다.


역시, 변수가 두개이기 때문에 각운동량 보존법칙 하나만으로는 문제는 풀 수 없습니다.
그러나 앞의 운동량 보존법칙과 각운동량 보존법칙을 연립하면, 식이 2개 변수가 2개 이기 때문에 변화량을 구할 수 있습니다.  정리하면 아래와 같습니다.


그러나 위의 식은 너무 복잡합니다. 지금은 m1, m2두개의 물체가 있을때 지만,  아래의 그림과 같은 상황이 되면 식을 풀기가 어렵습니다.

 


3) Center of Mass의 특성을 이용해, 물체(RigidBody)의 운동량(각운동량)해석을 간소화함
아래의 그림처럼 물체(RigidBody)의 속도는 기준점의 속도(Linear Velocity)와 기준점에 대한 상대속도(Angular Velocity)의 합으로 나타낼 수 있습니다.


기준점을 Center of Mass로 잡으면 , 운동량 보존법칙은 아래 식과 같습니다.
r은 Center of Mass로 부터의 거리, w는 각속도, Vc는 Center of Mass의 속도를 나타냅니다.
 



외부힘에 대한, 운동량의 변화량을 식으로 나타내면 아래와 같습니다.


마찬가지로, 각운동량의 측정지점을 아래 그림과 같이 Center of Mass로 합니다.


각운동량을 식으로 나타내면 아래와 같습니다.


F힘이 위치 PF위치에서 작용했을때, 각운동량의 변화량을 식으로 나타내면 아래와 같습니다.



여기서 식을 아래와 같이 정리 할 수 있습니다.



그리고 Inertia Tensor를 아래와 같이 정의 하게 됩니다.



Inertia Tensor를 이용해 운동량을 해석하면 아래와 같습니다.



4) 박스의 Inertai Tensor
예로 박스의 Inertia Tensor를 구해 보겠습니다.

Inertia Tensor는 아래식과 같습니다.


식을 x, y, z순으로 적분하면 아래과 같이 됩니다.



최종 Inertia Tensor는 아래와 같이 됩니다.



5) Inertia Tensor의 대각화
Bullet이나 대부분의 물리엔진은 Inertia Tensor를 행렬이 아닌 백터의 형태로 가지고 있습니다.
이는 Inertia Tensor의 3x3행열을 대각화 시킨 것 입니다.
박스, 구, 실린더, 캡슐등의 Inertia Tensor는기본적으로 대각화가 되어 있습니다. 위의 박스의 Inertia Tensor도 대각화 되어 있는 걸 확인하실 수 있습니다. (대각화를 구하는 방법은 따로 언급하지 않겠습니다.)
대각화된 Inertia Tensor는 아래와 같은 특징을 가집니다.


Inertia Tensor가 대각화 됬을 경우, 운동량식을  정리하면 아래와 같습니다.



이상으로 Inertia Tensor에 대한 설명을 마치겠습니다.

'물리' 카테고리의 다른 글

Inertia Tensor(1)  (0) 2012.02.13
RigidBody의 Restitution, Friction, Damping  (2) 2012.02.06
[Bullet Physics] RigidBody 만들기  (2) 2012.01.05
[Bullet Physics] Bullet 물리엔진의 설치  (2) 2011.12.22

Welcome to Parallel C#(16) - 분배 전략.

C# Parallel Programming 2012. 2. 17. 09:00 Posted by 알 수 없는 사용자
이번에는 TPL에서 제공하는 옵션에 대해서 알아보겠습니다. 병렬 작업을 위해서는 먼저 병렬로 수행할 수 있게끔 작업을 분할하는 일이 먼저 수행되어야 합니다. 예를 들어서 10000개의 데이터를 4개의 스레드가 나눠서 처리해야 하고, 각 10000개의 작업이 독립적이라면, 이걸 공평하게 4등분 할 것인지, 혹은 다른 방법을 사용할 것인지등을 결정하는 것 말이죠.


- 두 가지 선택.

TPL에서 작업 분할을 할때 사용하는 방법으로 구간 분할과 로드 밸런싱이 있습니다. 구간 분할을 루프의 크기를 스레드의 개수만큼 공평하게 나눠서 처리하는 것을 말합니다. 단순하죠.  :) 그리고 로드밸런싱은
처리할 구간을 미리 정하지 않고, 처리해야 할 작업에 따라서 각 스레드가 처리할 구간의 크기나 스레드의 개수가 유동적으로 변하게 됩니다. 이때 스레드하나가 이 작업을 담당하게 됩니다. 이 스레드는 작업 상황을 지켜보면서 그때 그때 스레드를 생성해서 작업을 맡기등의 역할을 맡게 되는 거죠. 그래서 작업을 끝낸 스레드가 다음에 처리할 구간을 할당받기 전까지 기다리는 시간이 필요하고, 이런 동기화에 따른 부하가 발생하게 되는 거죠. 나눠주는 구간의 크기가 작을 수록 스레드는 자주 구간을 할당받으려고 기다려야 하고 성능은 더 저하되는 거죠. :)

Parallel.For의 기본 동작은 로드 밸런싱을 사용하도록 되어 있습니다. 그래서 위에서 언급한 동기화 이슈가 자주 발생하는 경우 싱글스레드로 실행되는 for루프에 비해서 그다지 성능의 우위를 보이지 못하게 됩니다. 예제로 확인해볼까욤? ㅋㅋ

using System;

using System.Linq;

using System.Threading.Tasks;

using System.Collections.Concurrent;

using System.Collections.Generic;

 

namespace Exam20

{

    class Program

    {

        static void RangeParallelForEach(int[] nums)

        {

            List<int> threadIds = new List<int>();

            var part = Partitioner.Create(0, nums.Length);

 

            Parallel.ForEach(part, (num, state) =>

            {

                if (!threadIds.Contains(Task.CurrentId.Value))

                {

                    threadIds.Add(Task.CurrentId.Value);

                }

 

                for (int i = num.Item1; i < num.Item2; i++)

                {

                    nums[i] = nums[i] * nums[i];

                }

            });

 

            Console.WriteLine("Thread ID list of RangeForEach");

            foreach (var id in threadIds)

            {

                Console.WriteLine("{0}", id.ToString());

            }

        }

 

        static void ParallelFor(int[] nums)

        {

            List<int> threadIds = new List<int>();

 

            Parallel.For(0, nums.Length, (i) =>

            {

                if (!threadIds.Contains(Task.CurrentId.Value))

                {

                    threadIds.Add(Task.CurrentId.Value);

                }

 

                nums[i] = nums[i] * nums[i];

            });

 

            Console.WriteLine("Thread ID list of ParallelFor");

            foreach (var id in threadIds)

            {

                Console.WriteLine("{0}", id.ToString());

            }

        }

 

        static void Main(string[] args)

        {

            int max = 50000000;

            int[] nums1 = Enumerable.Range(1, max).ToArray<int>();

            int[] nums2 = Enumerable.Range(1, max).ToArray<int>();

 

            ParallelFor(nums1);

            RangeParallelForEach(nums2);

        }

    }

}

위 코드를 보면 로드밸런싱을 사용하는 Parallel.For와 Partitioner.Create 메서드를 이용해서 구간분할을 사용하도록 설정한 Parallel.ForEach를 사용하는 코드를 포함하고 있습니다. 이렇게 수행하면서 각 방법 별로 몇 개의 스레드가 생성되는지 확인한 결과를 볼까요.

Thread ID list of ParallelFor
1
2
3
4
5
6
7
8
9
10
--------------중략--------------
91
92
93
94
95
96
97
98
99
100
101
Thread ID list of RangeForEach
102
103
104
105
106
107
108
Press any key to continue . . .

구간 분할을 사용하는 경우(RangeForEach)는 고작 7개의 스레드가 생성되어서 작업을 처리한 것에 비해서, 로드밸런싱을 사용한 경우는 무려 101개의 스레드가 생성된 것을 볼 수 있습니다. 스레드가 많이 교체되면서 생기는 비효율성은 굳이 말을 안해도 되겠죵. 그렇다면 다음과 같은 코드로 실제로 어떤 속도의 차이가 발생하는지 확인을 해보시져 :)

using System;

using System.Linq;

using System.Threading.Tasks;

using System.Collections.Concurrent;

 

namespace Exam21

{

    class Program

    {

        static void NormalFor(int[] nums)

        {

            for (int i = 0; i < nums.Length; i++)

            {

                nums[i] = nums[i] * nums[i];

            }

        }

 

        static void ParallelFor(int[] nums)

        {

            Parallel.For(0, nums.Length, (i) =>

            {

                nums[i] = nums[i] * nums[i];

            });

        }

 

        static void RangeParallelForEach(int[] nums)

        {

            var part = Partitioner.Create(0, nums.Length);

 

            Parallel.ForEach(part, (num, state) =>

            {

                for (int i = num.Item1; i < num.Item2; i++)

                {

                    nums[i] = nums[i] * nums[i];

                }

            });

        }

 

        static void Main(string[] args)

        {

            int max = 50000000;

            int[] nums1 = Enumerable.Range(1, 10).ToArray<int>();

            int[] nums2 = Enumerable.Range(1, 10).ToArray<int>();

            int[] nums3 = Enumerable.Range(1, 10).ToArray<int>();

 

            //JIT컴파일을 위해.

            NormalFor(nums1);

            ParallelFor(nums2);

            RangeParallelForEach(nums3);

 

            nums1 = Enumerable.Range(1, max).ToArray<int>();

            nums2 = Enumerable.Range(1, max).ToArray<int>();

            nums3 = Enumerable.Range(1, max).ToArray<int>();

 

            DateTime normalForStart = DateTime.Now;

            NormalFor(nums1);

            DateTime normalForEnd = DateTime.Now;

            TimeSpan normalForResult = normalForEnd - normalForStart;

 

            DateTime parallelForStart = DateTime.Now;

            ParallelFor(nums2);

            DateTime parallelForEnd = DateTime.Now;

            TimeSpan parallelForResult = parallelForEnd - parallelForStart;

 

            DateTime rangeForStart = DateTime.Now;

            RangeParallelForEach(nums3);

            DateTime rangeForEnd = DateTime.Now;

            TimeSpan rangeForResult = rangeForEnd - rangeForStart;

 

            Console.WriteLine("Single-threaded for : {0}", normalForResult);

            Console.WriteLine("Load-balancing Parallel.For : {0}", parallelForResult);

            Console.WriteLine("Range-partition Parallel.ForEach : {0}", rangeForResult);

        }

    }

}

좀 어글리한 코드이긴 하지만, 싱글 스레드를 사용한 for루프와 로드 밸런싱을 사용하는 Parallel.For, 그리고 구간 분할을 사용하도록 한 Parallel.ForEach의 성능을 각각 비교한 코드입니다. 그럼 결과를 확인해보죠!

Single-threaded for : 00:00:00.3000763
Load-balancing Parallel.For : 00:00:00.3430858
Range-partition Parallel.ForEach : 00:00:00.2130545
Press any key to continue . . .

조금은 의외의 결과가 나왔습니다. 오히려 병렬로 작업을 처리한 Parallel.For가 순차적으로 실행되는 for루프에 비해서 낮은 성능을 낸 것이죠. 왜 그럴까욤? 첫 번째 원인은 너무 빈번하게 스레드 간의 작업 전환을 하느라 오버헤드가 컸던 점을 들 수 있을 것 같구요. 그리고 다음은, 병렬 작업에 사용되는 델리게이트의 내용이 너무 짧아서 입니다. 위 코드를 보면 델리게이트의 내용이 간단한 연산 한 줄로 이루어진 것을 볼 수 있는데요. 위 처럼 호출 수가 작은 경우는 큰 문제가 되지 않지만, 위의 코드 처럼 많은 횟수를 호출하는 경우 델리게이트 호출은 부담이 됩니다. 델리게이트의 내용을 처리하는 것 보다 델리게이트를 호출하는 시간이 더 많이 걸리게 되면서 일반 for루프보다도 못한 성능을 보여주는 거죠. 그래서 Partitioner.Create메서드를 이용해서 큰 작업 덩어리로 잘라줘서 이 오버헤드를 줄일 수 있습니다. 그 결과 위와 같은 시간이 나오게 된거죠.

어떻나요? 단순히 for루프를 Parallel.For로 교체하는 것만 가지고는 제대로된 성능을 기대하기 힘듭니다. 혹시, "어? 뭐야 당신 이전 포스트에서는 단순히 Parallel.For를 쓰기만 해도 성능이 좋아진다며?" 하는 생각이 드셨다면... 당신은 저의 팬...???!?!? ㅋㅋㅋㅋ 아마 기억하시는 분은 없겠죠. 암튼, 주의하고 볼일 입니다. 오늘은 여기까징~! :)

SharePoint 2010 ULS Viewer

SharePoint 2010 2012. 2. 16. 08:30 Posted by 알 수 없는 사용자

ULS Viewer

SharePoint 2010의 오류가 발생할 경우 상관관계 ID를 통해 오류 로그를 액세스하게 됩니다.

C 드라이브일 경우 위치는 아시다시피 아래와 같습니다.

C:\Program Files\Common Files\Microsoft Shared\Web Server Extensions\14\LOGS

오류를 볼 경우 찾기를 통해 해당 상관관계 ID를 찾게 되는데요.

ULS Viewer를 쓰시면 오류만 필터링하거나 해서 트러블슈팅하실 때 도움이 될 것 같습니다.

아래 링크를 참고하셔서 다운로드 받아서 서버에서 실행하면 됩니다.

http://archive.msdn.microsoft.com/ULSViewer

파일 메뉴의 Open From -> ULS를 통해서 로그를 확인이 가능하며 Level High 보시면 필터링해서 오류등의 결과 확인에 더 도움이 됩니다.


지난 포스팅에서 Break와 Stop메서드의 차이에 대해서 시덥잖은 퀴즈를 남겼었는데욤, 뭐 별로 궁금한 분들이 없거나 너무 쉬워서 코에서 바람 좀 내뿜은 분들이 많으리라 생각합니다. 저는 이게 뭔 차이인지 잠깐 헤맸습니다만... 허허허 :) 그럼 차이점에 대해서 잠깐 이야기를 해볼까욤.


- Parallel.For와 for의 브레끼는 닮았더라.

Parallel.For의 Break메서드는 for의 break와 좀 닮았습니다. for루프에서 break를 만나게 되면 그 즉시 for루프를 중단하게 됩니다. 그리고 break가 걸리기 전 까지의 인덱스에 대해서는 루프가 실행되었음을 보장할 수 있습니다. for루프는 처음부터 순차적으로 실행되기 때문이죠. 그런데 Parallel.For에서는 루프문의 길이에 대해서 각각의 스레드가 조금씩 나눠서 담당하게 됩니다. 예를 들면 1-10000까지의 구간이라면, 스레드 하나가 1-5000까지, 다른 스레드가 5001-10000까지 뭐 이런식으로 말이죠. 이런 상황에서 5100번째 인덱스에서 Break가 호출된다면 어떻게 될까요? 기존의 for루프를 사용하던 관점에서 보자면 당연히 5109번째 인덱스까지는 다 처리되었을 거라고 예상하게 됩니다. 이런 개발자의 기대를 저버리지 않기 위해서 Break메서드를 호출하게 되면 Break가 호출된 인덱스보다 작은 인데스에 대해서 남아있는 작업은 모두 실행하도록 합니다. 예제를 볼까요?

using System;

using System.Threading.Tasks;

 

namespace ConsoleApplication1

{

    class Program

    {

        static void Main(string[] args)

        {

            Parallel.For(0, 20, (i, loopState) =>

            {

                if (i == 10)

                {

                    loopState.Break();

                    Console.WriteLine("Break at {0}", i.ToString());

                }

                else

                {

                    Console.WriteLine(i.ToString());

                }

            });

        }

    }

}


아주 간단하게 0부터 19까지 Parallel.For루프를 돌면서 i가 10일때 Break를 호출해서 루프를 중단하도록 하는 코드입니다. 실행할 때마다 결과가 다르게 나오는데요, 그 중에 가장 설명하기 좋은 결과를 고르면!

0

1

2

3

4

6

5

Break at 10

7

8

9

Press any key to continue . . .


대략 이렇습니다. 스레드가 구간을 나눠서 루프를 실행하는데, 먼저 실행한 스레드가 0-6까지를 처리하고 다른 스레드가 10을 처리하는 순간 Break를 만나게 되는 거죠. 하지만 여기서 중단 되지 않고, 다른 스레드가 7, 8, 9를 처리하도록 해줍니다. 그래서 10에서 Break가 호출되었더라도 바로 중단하지 않고 10보다 작은 인덱스가 모두 처리되도록 해주는 것이죠.


- 장비를 정지합니다... 어 안되잖아?

Break를 즉시 중단하는 개념으로 생각하다 보면 조금은 이상할 수 있습니다만, 사실 기존의 for루프와 유사한 동작을 제공한다는 개념에서는 바람직한 동작이 아닌가 싶습니다. 그런데, 데이터에서 뭔가를 검색하는 경우는 어떨까요? 4명이 근무하는 사무실을 생각해봅시다. 뭔가 중요한 서류를 찾아야 해서 사장 이하 4명이 구역을 나눠서 사무실을 뒤지기 시작합니다. 한 명은 책상을, 한 명은 캐비넷을, 또 한 명은 창고를, 또 한 명은 잉여짓을. 이렇게 4명이 찾다가 누군가 한 명이 그 서류를 발견했습니다!!! 그러면 그 사람은 '심봤다 어허으어릉러허엏엉 나 심봤쪄영 뿌우 'ㅅ'~'하고 외치겠죠? 그러면 그 사람들은 그 찾는 작업을 멈추게 됩니다. 그게 정상이겠죠. '그럴리가 없엉 어흐어흐어흐러라어헝'하면서 나머지를 계속 찾는 사람은 '안돼에~'. 허허허허 :)

이런 경우에는 Parallel.For를 즉시 중시해야 합니다. 그럴때! 바로 Stop을 호출하게 됩니다.

using System;

using System.Threading.Tasks;

 

namespace ConsoleApplication1

{

    class Program

    {

        static void Main(string[] args)

        {

            Parallel.For(0, 20, (i, loopState) =>

            {

                if (i == 10)

                {

                    loopState.Stop();

                    Console.WriteLine("Stop at {0}", i.ToString());

                }

                else

                {

                    Console.WriteLine(i.ToString());

                }

            });

        }

    }

}

Break대신에 Stop을 호출한 코드죠. 결과능!

0

5

Stop at 10

Press any key to continue . . .


짤 없습니다. 그냥 중단해버리는 거죠. 자 지난 포스팅에서 왜 Stop을 호출한 게 미세하게 빨랐는지 아시겠나요? 소수는 1과 자신 외에 하나라도 나눌 수 있는 숫자가 존재하면 안되는 거죠. 그래서 for루프에서 숫자를 바꿔가면서 나눠보다가 하나라도 나눠지는 게 발견되면 그 즉시 중단하는 게 맞는 거죠. Break는 발견되더라도, 그 보다 작은 인덱스에 대해서는 모두 수행하게 되기 때문에 Stop에 비해서 아주 미세하게 느린 결과가 나오게 되는 거죵. 허허허허허 :) 참 별 내용 없네요 ㅠㅠ...

그럼 오늘은 여기까징~!

Welcome to Parallel C#(14.2) - 긴급 패치 2.

C# Parallel Programming 2012. 2. 14. 09:15 Posted by 알 수 없는 사용자

김명신님께서 또 아주 좋은 지적을 해주셨습니다~~ List<T>도 ConcurrentBag<T>이든 하나의 컬렉션을 이용하는 건 바람직하지 않은 것 같다는 의견이셨는데요, 동감합니다 :) 그래서 제 나름대로 해결책을 강구해봤는데욤. 스레드 로컬 변수를 이용해서 스레드 별로 작업을 시키고, 나중에 하나의 리스트로 모으는 것입니다. 코드를 볼까욤~?

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
 
namespace ConsoleApplication1
{
    class Program
    {
        static readonly object _sync = new object();
 
        static IEnumerable<long> GetPrimeNumber(long num)
        {
            List<long> primeList = new List<long>();
 
            Parallel.For<List<long>>(0, num + 1, 
                () => new List<long>(), //스레드 로컬 변수 초기화
                (i, outerLoopState, subList) =>
            {
                bool isPrime = true;
                Parallel.For(2, i, (j, loopState) =>
                {
                    if (i % j == 0)
                    {
                        isPrime = false;
                        loopState.Break();
                    }
                });
 
                if (isPrime)
                {
                    subList.Add(i); //스레드 로컬 리스트에 추가
                }
 
                return subList;
            },
            (subList) => //각 스레드 종료 후에 취합
            {
                lock (_sync)
                {
                    primeList.AddRange(subList);
                }
            });
 
            return primeList;
        }
 
        static void Main(string[] args)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            IEnumerable<long> primeList = GetPrimeNumber(99999);
            sw.Stop();
 
            Console.WriteLine("Elapsed : {0}, Found prime counts : {1}",
                sw.Elapsed.ToString(),
                primeList.Count());
            //뭔가 한다.
        }
    }
}

위 코드를 보면, Parallel.For 루프에서 스레드 로컬 변수를 사용하도록 밑줄 친 부분들이 추가된 것을 볼 수 있습니다. 즉 이 Parallel.For는 List<long>타입의 스레드 지역 변수를 사용하며, 그 지역변수를 어떻게 초기화 할 것인지를 명시하고, 각 스레드가 종료할 때 지역 변수를 리턴하도록 한 것이죠. 그리고 Parallel.For의 마지막 매개변수로, 각 스레드가 종료할 때 어떤 동작을 취할 것인가를 명시하는데, 스레드가 끝날 때 리턴한 지역 변수가 매개변수로 들어오게 됩니다. 이렇게 하면 동기화를 좀 더 줄일 수 있는 것이죠. 그래서, 이왕 코드를 고친 김에 시간을 재봤습니다.

   ConcurrentBag<T>사용(초)  스레드 지역 변수 사용(초)
 GetPrimeNumber(99999)  2.54  2.52 
 GetPrimeNumber(199999)  9.32  9.25
 GetPrimeNumber(299999)  20.03  19.96

미세하게 스레드 지역 변수를 사용한 쪽이 빠르긴 한데요, 위 코드의 경우 하나의 리스트로 값을 합치는 데 시간이 좀 소요되어서 큰 속도 향상이 없는 것으로 보입니다. 동기화해서 수행해야 하는 작업이 간단할 수록 속도차이는 벌어질 것으로 보이는데요, 가능하면 각자 작업 후에 하나로 합치는 쪽이 더 효율적일 것 같네요.

부족한 실력이다 보니 이 이상 뭐가 떠오르지 않네요 -_-;; 더 나은 방법이 있으면 알려주세용~ :)

Welcome to Parallel C#(14.1) - 긴급 패치.

C# Parallel Programming 2012. 2. 13. 20:23 Posted by 알 수 없는 사용자

신석현님께서 아주 좋은 지적을 해주셨습니다. 제가 예제로 사용한 코드를 돌리면 매번 prime count가 다르게 나온다는 것이었습니다. 참고를 위해서~~ 다시 그 문제의 코드를 붙여 보겠습니당. :)

using System;

using System.Collections.Concurrent;

using System.Collections.Generic;

using System.Diagnostics;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace ConsoleApplication1

{

    class Program

    {

        static IEnumerable<long> GetPrimeNumber(long num)

        {

            List<long> primeList = new List<long>();

 

            Parallel.For(0, num + 1, (i) =>

            {

                bool isPrime = true;

                Parallel.For(2, i, (j, loopState) =>

                {

                    if (i % j == 0)

                    {

                        isPrime = false;

                        loopState.Break();

                    }

                });

 

                if (isPrime)

                {

                    primeList.Add(i);

                }

            });

 

            return primeList;

        }

 

        static void Main(string[] args)

        {

            Stopwatch sw = new Stopwatch();

            sw.Start();

            IEnumerable<long> primeList = GetPrimeNumber(99999);

            sw.Stop();

 

            Console.WriteLine("Elapsed : {0}, Found prime counts : {1}",

                sw.Elapsed.ToString(),

                primeList.Count());

            //뭔가 한다.

        }

    }

}


이 코드에서 문제가 되는 곳은 바로~!!!!! List<T>를 사용했다는 점입니다. Parallel.For를 돌리면, 여러 스레드가 구역을 나눠서 동시에 Parallel.For안의 코드를 돌리게 되는데요. 이 과정에서 스레드 간의 충돌이 생겨서 그렇습니다. 문제를 찾아보기 위해서 리플렉터로 List<T>.Add(T)의 코드를 찾아보니...

public void Add(T item)

{

    if (this._size == this._items.Length)

    {

        this.EnsureCapacity(this._size + 1);

    }

    this._items[this._size++] = item;

    this._version++;

}


이렇게 되어 있습니다. 여기서 7번째 줄의 'this._size++'부분이 문제가 되는 것 같습니다. 제가 11번째 포스팅에서 적은 내용 처럼 말이죠. 그래서 size가 1증가 되기 전에 같은 같은 인덱스에 값이 두번 쓰여지는 것이 아닌가 하는 추측이 가네욤!!! :)

그래서 가설을 확인해보기 위해서 List<T>를 여러스레드가 동시에 사용가능한 ConcurrentBag<T>로 바꿔서 테스트를 진행해봤습니다. 그랬더니!! 매번 같은 prime count가 나오는 것을 볼 수 있었습니다. 하하하하하 ㅠ_ㅠ;; 참고로 ConcurrentBag<T>.Add<T>의 코드를 봤더니 내부적으로...

private void AddInternal(ThreadLocalList<T> list, T item)

{

    bool lockTaken = false;

    try

    {

        Interlocked.Exchange(ref list.m_currentOp, 1);

        if ((list.Count < 2) || this.m_needSync)

        {

            list.m_currentOp = 0;

            Monitor.Enter(list, ref lockTaken);

        }

        list.Add(item, lockTaken);

    }

    finally

    {

        list.m_currentOp = 0;

        if (lockTaken)

        {

            Monitor.Exit(list);

        }

    }

}


위와 같은 코드를 사용하더군요. Monitor를 사용해서 동기화를 시키고 있죠~? 하하하 :) 날카로운 질문을 해주신 신석현님께 감사드립니다 :)

Welcome to Parallel C#(14) - 거기까지.

C# Parallel Programming 2012. 2. 13. 09:00 Posted by 알 수 없는 사용자

자~ 그럼 이제 부터는 다시 병렬 프로그래밍으로 돌아가서 조금 이야기를 해보도록 할 까욤~? 오래전에 써놓은 자료를 보다보니..... 제가 쓴 내용이지만 무슨 내용인지 이해가 안되는 글이 많아서 다시 정리를 하느라 조금 애를 먹고 있습니다. 하하하하 :) 뭐 제 수준이 딱 거기 까지니까 말이죵 호호호호.


- 병렬 작업을 중단할 때능?

기존의 for루프를 중단할 때는 그냥 간단하게 break하나 추가해넣으면 되었습니다. for루프는 정해진 순서에 따라서 순차적으로 실행되기 때문에 특정 조건에서 break를 만나서 루프가 중단 된다고 하더라도 항상 취소되기 이전까지의 내용은 모두 실행이 되었음을 보장할 수 있었죠. 즉,

for (int i = 0; i < 100; i++)

{

    if (i == 50)

    {

        break;

    }

}


위와 같은 코드가 있다고 할 때, i가 50이되어서 for루프가 중단된다고 할 때, i가 0-49일때는 이미 다 진행된 상태라는 것이죠. 그런데 이런 루프를 빠르게 처리하기 위해서 여러 스레드를 동시에 사용하는 Parallel.For를 사용했다고 한다면... 각 스레드가 나눠서 진행중인 작업을 어떻게 중단 시켜야 할까요?


- 브레이크를 걸자.

일단은 기존의 for루프와 가장 유사한 형태를 보이는 것 부터 살펴봅시당. 일단 비교를 위해서 다음과 같은 예제를 먼저 살펴보겠습니다.

using System;

using System.Collections.Concurrent;

using System.Collections.Generic;

using System.Diagnostics;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace ConsoleApplication1

{

    class Program

    {

        static IEnumerable<long> GetPrimeNumber(long num)

        {

            List<long> primeList = new List<long>();

 

            for (long i = 0; i <= num; i++)

            {

                bool isPrime = true;

                for (long j = 2; j < i; j++)

                {

                    if (i % j == 0)

                    {

                        isPrime = false;

                        break;

                    }

                }

                if (isPrime)

                {

                    primeList.Add(i);

                }

            }

 

            return primeList;

        }

       

        static void Main(string[] args)

        {

            Stopwatch sw = new Stopwatch();

            sw.Start();

            IEnumerable<long> primeList = GetPrimeNumber(99999);

            sw.Stop();

 

            Console.WriteLine("Elapsed : {0}, Found prime counts : {1}",

                sw.Elapsed.ToString(),

                primeList.Count());

            //뭔가 한다.

        }

    }

}


아주 간단한 코드인데요, 1부터 사용자가 입력한 숫자 중에서 소수를 구해서 리스트에 추가하는 코드입니다. 그리고 각 수가 소수인지 검사하는 부분에서 1이 아닌 수로 나눠지는 케이스가 발견 되면 그 즉시 break를 이용해서 중단을 시키고 있죠. 위 코드를 실행하면 CPU점유율은 아래와 같이 단일 스레드를 이용하는 것을 볼 수 있습니다.


그리고 위 코드를 3번 실행해서 얻은 평균 실행 시간은 6.28초 입니다. 그렇다면 위 코드의 Parallel.For버전을 살펴 볼까욤?

using System;

using System.Collections.Concurrent;

using System.Collections.Generic;

using System.Diagnostics;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace ConsoleApplication1

{

    class Program

    {

        static IEnumerable<long> GetPrimeNumber(long num)

        {

            List<long> primeList = new List<long>();

 

            Parallel.For(0, num + 1, (i) =>

            {

                bool isPrime = true;

                Parallel.For(2, i, (j, loopState) =>

                {

                    if (i % j == 0)

                    {

                        isPrime = false;

                        loopState.Break();

                    }

                });

 

                if (isPrime)

                {

                    primeList.Add(i);

                }

            });

 

            return primeList;

        }

       

        static void Main(string[] args)

        {

            Stopwatch sw = new Stopwatch();

            sw.Start();

            IEnumerable<long> primeList = GetPrimeNumber(99999);

            sw.Stop();

 

            Console.WriteLine("Elapsed : {0}, Found prime counts : {1}",

                sw.Elapsed.ToString(),

                primeList.Count());

            //뭔가 한다.

        }

    }

}


for를 Parallel.For로 바꿨고, break역시 Parallel.For의 인자로 넘어오는 loopState변수에 대해서 Break메서드를 호출하는 것으로 변경되었습니다. 이 코드를 실행해보면, 아래와 같이 CPU의 4개의 코어가 모두 동작하는 것을 볼 수 있습니다.




그리고 역시 위 코드를 3번 실행해서 얻은 평균 실행 시간은 5.32 초입니다. 전에 비해서 약 15%의 성능향상이 있었습니다. 물론 소수를 구하는 알고리즘 자체를 더 개선할 수도 있지만, 단순히 싱글 스레드를 이용한 코드와 작업을 병렬화 시켜서 멀티 코어를 이용한 코드간의 차이가 이정도라면 의미가 있지 않을까요? 그리고 좀더 CPU를 많이 사용하는 코드일 수록 그 차이는 더 벌어지겠죠 :)


- 그런데 사실은...

위 코드에서 몇자를 바꾸고 나면, 코드의 평균 실행 시간이 5.29초로 조금 더 떨어지게 됩니다. 그 코드를 올려드릴 테니 어디를 수정한 건지 한번 찾아보시죵 :)

using System;

using System.Collections.Concurrent;

using System.Collections.Generic;

using System.Diagnostics;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace ConsoleApplication1

{

    class Program

    {

        static IEnumerable<long> GetPrimeNumber(long num)

        {

            List<long> primeList = new List<long>();

 

            Parallel.For(0, num + 1, (i) =>

            {

                bool isPrime = true;

                Parallel.For(2, i, (j, loopState) =>

                {

                    if (i % j == 0)

                    {

                        isPrime = false;

                        loopState.Stop();

                    }

                });

 

                if (isPrime)

                {

                    primeList.Add(i);

                }

            });

 

            return primeList;

        }

        

        static void Main(string[] args)

        {

            Stopwatch sw = new Stopwatch();

            sw.Start();

            IEnumerable<long> primeList = GetPrimeNumber(99999);

            sw.Stop();

 

            Console.WriteLine("Elapsed : {0}, Found prime counts : {1}",

                sw.Elapsed.ToString(),

                primeList.Count());

            //뭔가 한다.

        }

    }

}


어딘지 찾으셨나요? 바로 loopState변수에 대해서 Break를 호출하던 것을 Stop으로 바꿔준 것입니다. 그래서 조금 더 효율적인 코드가 되는 것이죠. 왜 그럴까요? 그거에 대해서는!!!! 다음 포스팅에서 ㅋㅋㅋㅋㅋ


- 참고자료
http://stackoverflow.com/questions/1510124/program-to-find-prime-numbers

Inertia Tensor(1)

물리 2012. 2. 13. 00:09 Posted by 알 수 없는 사용자
이번에는 Inertia Tensor에 대해서 설명 드리겠습니다.
Inertia Tensor를 이해하기 위해서는,  운동량(Linear Momentum), 각운동량(Angulra Momentum), 질량중심(Center of Mass)에 대해서 알고 있어야 합니다. 그래서 이 3요소에 대해서 먼저 언급을 한 후에,  Inerta Tensor에 대해 설명 드리겠습니다.


1) 운동량(Linear Momentum)

운동량은 말 그대로 물체가 현재 운동하고 있는 정도를 나타냅니다.
아래의 그림처럼, 질량이 m1, 위치가 p1, 속도가 v1이라 물체가 있을때 이 물체의 운동량은 아래 식과 같습니다.




여기서 ML1은 1번 물체의 운동량(Linear Momentum)을 나타냅니다.
이 운동량을 시간에 대해서 미분하면(시간에 따른 변화를 측정) 아래와 같은 식이 나옵니다.


이 식을 보면, 물체의 운동량을 변화시키기 위해서는 힘이 필요하고, 외부의 힘이 없으면 물체의 운동량은 일정하게 유지 됩니다.
이게 바로 운동량 보존의 법칙입니다.


2) 각운동량(Angular Momentum)

각운동량은 임의의 기준점에 대한, 물체의 회전 운동량을 나타냅니다.
아래의 그림처럼, 질량이 m1, 위치가 p1, 속도가 v1이라 물체가 있을때, p점에서 측정한 이 물체의 각운동량은 아래의 식과 같습니다.




여기서 MA1은 1번 물체의 각운동량(Angular Momentum)을 나타냅니다.
이 각운동량을 시간에 대해서 미분하면(시간에 따른 변화를 측정) 아래와 같은 식이 나옵니다.

 



이 식을 보면, 물체의 각운동량을 변화시키기 위해서는 힘이 필요하고, 외부의 힘이 없으면 물체의 각운동량은 일정하게 유지 됩니다. 이게 바로 각운동량 보존의 법칙입니다.
그리고  각운동량은 운동량과 달리, 아래의 그림처럼 측정 지점에 따라 다르게 나타납니다.




3) 질량중심(Center of mass)
물체(RigidBody)는 아래의 그림처럼 미소질량들의 모임으로 해석할 수 있습니다. 


질량중심은 두가지 의미를 가지고 있습니다.
(1) 물체의 각 미소질량들이 동일한 힘을 받을때,  질량중심은 아래 그림처럼 그 물리량을 대표할 수 있는 하나의 지점을 의미합니다. 미소질량들이 받는 동일한 힘의 대표적인 예가 중력입니다. 질량중심에 반대의 힘을 가하면 물체의 운동량은 정지되게 됩니다. (중심을 잡게 됩니다.)


식으로 나타내면 아래와 같습니다.
여기서 Pc가 구하고자 하는 질량 중심이고, Pxyz는 미소질량의 위치, P는 측정 위치를 나타냅니다.



이식을 풀면, 아래과 같은 식이 나옵니다.


(2) 질량중심에서는 아래의 그림처럼 상대운동(속도, 가속도)이 모두 0이 됩니다.


식으로 나타내면 아래와 같습니다.
아래의 식을 풀어도 (1)과 같은 결과를 얻습니다.


이상으로 운동량, 각운도량, 질량중심에 대한 설명을 마치겠습니다.

 


 

'물리' 카테고리의 다른 글

Inertia Tensor(2)  (0) 2012.02.19
RigidBody의 Restitution, Friction, Damping  (2) 2012.02.06
[Bullet Physics] RigidBody 만들기  (2) 2012.01.05
[Bullet Physics] Bullet 물리엔진의 설치  (2) 2011.12.22

Welcome to Parallel C#(13) - 더 편하겡... 더 빠르겡...

C# Parallel Programming 2012. 2. 10. 09:00 Posted by 알 수 없는 사용자

- 뭐 좀 더 편한 방법이?

지난 포스팅에서는 Monitor를 좀 더 간편하게 사용할 수 있게 해주는 lock구문에 대해서 살펴봤었습니다. 그런데... 더 편한 방법이 있다는 군요. 한 줄이면 된다고 합니다. 긴 말 필요없이 예제를 보죠!

using System;

using System.Threading.Tasks;

using System.Threading;

 

namespace Exam25

{

    class Program

    {

        static readonly int count = 10000000;

        static int sum = 0;

 

        static void IncreaseByOne()

        {

            for (int i = 0; i < count; i++)

            {

                Interlocked.Increment(ref sum);

            }

        }

 

        static void Main(string[] args)

        {

            Task task = Task.Factory.StartNew(IncreaseByOne);

 

            for (int i = 0; i < count; i++)

            {

                Interlocked.Decrement(ref sum);

            }

 

            task.Wait();

            Console.WriteLine("Result = {0}", sum);

        }

    }

}

위 예제는 정확하게 lock을 사용한 예제와 동일한 결과를 냅니다. Interlocked라는 클래스에 정의된 메서드를 통해서 1씩 증가시키고 감소를 시킨거죠. 즉, lock사용해서 명시적으로 락을 획득하는 대신에 스레드 안전한 방식으로 값을 증가시키고 감소시키도록 하는 것입니다. 그런데, 여기까지만 보면 약간의 의문이 생깁니다. 생각보다 더 간단해 보이지가 않는 다는 말이죠. 허허허허 :)

그런데 한 가지 장점이 더 있습니다. 바로 수행 속도 인데요. 잠금을 획득하고 해제하는 게 시간이 많이 걸리는 작업이다 보니, lock을 사용하는 것보다 Interlocked를 사용하는 게 조금 더 빠릅니다. 그럼 얼마나 더 빠르다는 건지 간단한 코드를 통해서 직접 확인을 해볼까욤?

using System;
using System.Threading.Tasks;
using System.Threading;

namespace Exam26
{
    class Program
    {
        readonly static object sync = new object();
        static readonly int count = 10000000;
        static int sum = 0;       

        static void SpeedTest()
        {
            DateTime lockStart = DateTime.Now;
            for (int i = 0; i < count; i++)
            {
                lock (sync)
                {
                    sum++;
                }
            }
            DateTime lockEnd = DateTime.Now;
            TimeSpan lockResult = lockEnd - lockStart;

            sum = 0;

            DateTime InterlockedStart = DateTime.Now;

            for (int i = 0; i < count; i++)
            {
                Interlocked.Increment(ref sum);
            }
            DateTime InterlockedEnd = DateTime.Now;
            TimeSpan InterlockedResult = InterlockedEnd - InterlockedStart;

            Console.WriteLine("lock Result : {0}", lockResult);
            Console.WriteLine("Interlocked Result : {0}", InterlockedResult);
        }

        static void Main(string[] args)
        {
            //JIT 컴파일 수행
            SpeedTest();

            SpeedTest();
        }
    }
}


수행된 결과를 볼까요?

lock Result : 00:00:00.3270844
Interlocked Result : 00:00:00.1820249
lock Result : 00:00:00.3090990
Interlocked Result : 00:00:00.1820285
Press any key to continue . . .

두 번째 실행된 결과를 보면 Interlocked를 사용한 쪽이 70%정도 더 빠른 속도를 보이는 것을 할 수 있습니다. 위 코드 같이 단순한 연산이 많이 수행되는 경우에는 Interlocked를 사용하는 쪽이 조금 더 유리할 수 있겠죠. 물론 요즘 세상에 별로 신경쓸만한 차이는 아니라고 생각이 듭니다. 하하하하 그냥 이런 것도 있다는 거죠 :)

그럼, 오늘도 이만 하구요~ 다음에 또 뵙죵 하하하 :)

Welcome to Parallel C#(12) - 귀찮으면 안 해.

C# Parallel Programming 2012. 2. 8. 09:00 Posted by 알 수 없는 사용자
- 인류 발전의 원동력.

귀.찮.다. 이 세자로 이루어진 표현이 인류의 발전을 이끌어왔다면 확대해석일까욤. 정말 죠낸죠낸쵸오낸 귀찮다 보니 사람들은 어떻게 하면 반복적인 작업을 줄일 수 있을까 하는 문제를 생각하게 됩니다. 그래서 더 나은 방법을 고안해 내고, 더 빠르고 더 간편한 시스템, 그리고 나아가서 자동화를 생각하게 됩니다. 그러다 보니, 수작업으로 이루어진 프로세스는 실수의 여지가 많은 반면에 자동화로 이루어진 프로세스에서는 그 여지가 굉장히 줄어들게 되는 부수적인 효과를 얻게 되는 것이죠.

물론 귀찮음으로 인해서 인류는 많은 퇴보도 했을 겁니다. 복잡하고 비효율적인 것도 몸에 익으면 편하게 느껴지게 됩니다. 그리고 더 깔끔한 방법으로 바꾸려 하면 머리속에서는 엄청난 저항이 일어나게 되죠. 이미 한번 구성되어서 굳어진 뇌의 회로를 다시 구성해야 한다는 그...... 귀찮음. 허허허허허허


- 락에 자동화를 도입!

지난 포스트에서 Monitor를 이용해서 락을 거는 방법을 알아봤습니다. 하지만 어떤가요? 매번 try-finally블록을 사용해서 락을 걸고 해야 한다면? 그리고 Enter는 있는데 Exit를 깜빡하게 빼먹었다면? 어머나!!!!

그래서 C#에서는 귀찮음을 싫어하는 인류의 특성을 감안해서 간편한 Syntactic Sugar를 제공합니다. lock이라는 구문이 바로 그것입니다. 하하하하 :) 그럼 간단한 예를 보죠.

using System;

using System.Threading.Tasks;

 

namespace Exam24

{

    class Program

    {

        readonly static object sync = new object();

        static readonly int count = 10000000;

        static int sum = 0;

 

        static void IncreaseByOne()

        {

            for (int i = 0; i < count; i++)

            {

                lock (sync)

                {

                    sum += 1;

                }

            }

        }

 

        static void Main(string[] args)

        {

            Task task = Task.Factory.StartNew(IncreaseByOne);

 

            for (int i = 0; i < count; i++)

            {

                lock (sync)

                {

                    sum -= 1;

                }

            }

 

            task.Wait();

            Console.WriteLine("Result = {0}", sum);

        }

    }

}

어떤가요? Monitor를 사용해서 해주던 번거로운 작업이 lock이라는 구문으로 임계영역을 감싸주는 것으로 간단하게 해결 되었습니다. 자, 그럼 lock이라는 편리한 구문 뒤에 실제로 생성되는 코드를 한번 확인해 볼까욤?

private static void Main(string[] args)

{

    Task task = Task.Factory.StartNew(new Action(Program.IncreaseByOne));

    for (int i = 0; i < count; i++)

    {

        object CS$2$0000;

        bool <>s__LockTaken1 = false;

        try

        {

            Monitor.Enter(CS$2$0000 = sync, ref <>s__LockTaken1);

            sum--;

        }

        finally

        {

            if (<>s__LockTaken1)

            {

                Monitor.Exit(CS$2$0000);

            }

        }

    }

    task.Wait();

    Console.WriteLine("Result = {0}", sum);

}

 

private static void IncreaseByOne()

{

    for (int i = 0; i < count; i++)

    {

        object CS$2$0000;

        bool <>s__LockTaken0 = false;

        try

        {

            Monitor.Enter(CS$2$0000 = sync, ref <>s__LockTaken0);

            sum++;

        }

        finally

        {

            if (<>s__LockTaken0)

            {

                Monitor.Exit(CS$2$0000);

            }

        }

    }

}

위에서 볼 수 있듯이 생성되는 코드는 Moniter를 이용한 코드와 정확하게 같은 코드를 사용하고 있습니다. 대신에 훨씬 간단하죠? 물론 lock구문을 통해서 락을 걸어줄 객체는 Monitor를 사용할 때와 동일하게 readonly static이어야 합니다.
 
락을 거는 대상이 되는 잠금 객체에 대해서 조금 덧붙이자면, 락을 걸어줄 객체는 값 형(Value 타입)이 아닌 참조 형(Reference 타입) 객체여야 합니다. 왜냐면, 값 형의 경우는 다른 변수에 값을 대입할 때 값을 복사해서 넘겨 주기 때문에 매번 메모리에 동일한 값이 새로 생겨나게 됩니다. 이렇게 되면 Enter메서드에서 받는 잠금 객체와 Exit메서드에서 받는 잠금 객체가 서로 다른 객체가 되므로 SynchronizationLockException이 발생하게 됩니다. 반면에 참조 형 객체의 경우는 동일한 객체에 대한 참조를 복사해서 넘겨주므로 항상 동일한 객체에 대해서 Enter와 Exit를 호출하게 되겠죠.

그럼~ 다음에 또 뵙죵~ :)

Welcome to Parallel C#(11) - 동기 부여.

C# Parallel Programming 2012. 2. 6. 09:00 Posted by 알 수 없는 사용자

우와~!!!! 증말 오랜만입니다. 이게 얼마만이죠? 하하하하하하하하-_-

기다리는 분이 아무도 없다는 건 알지만, 저 자신을 위해서 그냥 한번 호들갑을 떨어봤습니다. 허허허허 원래 제가 쓰는 포스팅이 다 그렇죠. 병렬 프로그래밍에 대해서 정리해놓은 자료가 조금 있었는데요, 매우 늦긴 했지만 그래도 일단 공부하면서 정리해놓은 자료를 그냥 썩히기는 아까워서 조금씩 옮겨 적어볼까 합니다. 그래서 다시 시작하는 거지요! 호호호호 :)

언제나 그렇듯이 제 포스팅은 공부하면서 정리하는 겁니다. 다른 분들이 적으시는 것 처럼 내공이 깊지도 않고, 그다지 실용적이지도 않지요. 의견이 있으시면 거침없이! 하지만 예의는 쪼금 지켜 주시면서! 가르침을 더 해주시면 읽으시는 분들에게 더 도움이 될 겁니다. 짧은 인생 Love & Peace! 그럼 또 초큼씩 풀어 볼까욤? ㅋㅋㅋ


- 화장실이 한 개!!!

4인 가족 기준으로 화장실이 한 개라고 생각을 하면..... 어머나!!! 끔찍해!!! 출근 시간이 되면 화장실에 응가를 때리러 러시아워가 벌어집니다. 먼저 들어간 사람은 느긋하게 다이빙하는 응가에 예술 점수를 매기면서 시간을 보낼 수 있지만, 출근 시간과 이제 그만 포기하자고 하는 장과 협상을 벌이면서 조마조마해야 하죠. 화장실을 원하는 사람은 4명인데, 화장실이 하나이다 보니, 먼저 들어간 사람이 화장실을 선점하고 문을 잠가서 락을 걸어버리면, 화장실을 원하는 다른 사람들은 그 락이 해제 될 때까지 인고의 시간을 보내야 하는 거죠. 어머나 슬픈이야기...ㅠㅠ

그러면 락을 걸지 못하게 하면 효과적인 해결책이 될까요? 일보고 있는데 갑자기 문을 열고 들어와서는 '난 얼굴 씻고 이빨만 닦으련다 변기를 쓰는 것도 아니니 너랑 내가 이 화장실을 평화롭게 공유하면서 각박한 출근 시간에 조그마한 훈훈함이라도 나눠가지지 않겠느냐' 하면 뭐 괘안을 수도 있겠습니다만.... 조금만 더 생각을 해보면 말이죠. 응가라는 건 굉장한 집중이 요구됩니다. 그리고 아무도 보는 사람이 없다는 안도감 속에서야 차분히 진행될 수 있는 일종의 비밀 의식 같은 거죠. 화장실을 공유하는 훈훈함 속에서 응가를 제대로 할 수 는 없습니다. 그리고 세면을 하는 사람 역시 은은하게 풍겨나오는 향기에 정신을 빼앗기다 보면 어서빨리 탈출하고 싶은 욕구에 사로잡히게 되어 세면을 제대로 할 수 없게 됩니다.

즉, 락을 거는 게 가장 효율적이라는 거죠. 여담이지만 그래서 저는 공중화장실에서는 응가를 안하는 편입니다. 정신 사나워서 집중을 할 수 없거든요. :)


- 그래서 필요한 동기화

그래서 프로그래밍을 하면서도 동기화에 신경을 써줘야 합니다. 컴퓨터의 자원을 한정적이고 그걸 호시탐탐 선점하려는 스레드의 욕구를 잘 제어해줘야 하기 때문이죠. 그럼 동기화 없이 스레드의 탐욕에 맡긴 결과가 어떻게 되나 한번 볼까요. 후후후후

using System;

using System.Threading.Tasks;

 

namespace Exam22

{

    class Program

    {

        static readonly int count = 10000000;

        static int sum = 0;

 

        static void IncreaseByOne()

        {

            for (int i = 0; i < count; i++)

            {

                sum += 1;

            }

        }

 

        static void Main(string[] args)

        {

            Task task = Task.Factory.StartNew(IncreaseByOne);

 

            for (int i = 0; i < count; i++)

            {

                sum -= 1;

            }

 

            task.Wait();

            Console.WriteLine("Result = {0}", sum);

        }

    }

}

두 개의 스레드를 이용해서, 주 스레드에서는 sum의 값을 1씩 감소 시키고, 추가 스레드에서는 증가 시키도록 해놓은 평범한 예제입니다. 하지만, 서로 뺏고 뺏기는 리얼 야생 탐욕이 살아 숨쉬는 정글의 법칙이 녹아들어 있습니다. 결과를 보시죠.

Result = 17104

계속하려면 아무 키나 누르십시오...

분명히 같은 횟수로 1씩 증가시키고 감소를 시켰는데 이런 결과가 나오다니!!! 매번 실행 할때 마다 다른 값이 나오긴 하지만, 이 결과는 분명 이상합니다. 오래전 포스팅이지만 원자성에 대해서 잠깐 이야기를 한적이 있었는데요, 특정 작업이 실행 될 때 그 작업 외에 다른 작업이 끼어들어서는 안된다는 원칙을 이야기 하는 것입니다. 그렇다면! 위에서 사용중인 +=, -= 연산자는 분명히 원자성을 위반하고 있는 것으로 보입니다.

단순하게 'a += 1' 을 가지고 생각해볼까욤. 이 연산은 몇 가지 단계 나누어 집니다. 우선 a의 값을 가져오고, 가져온 값을 1 증가 시키고, 증가된 값을 다시 a에 대입하는 단계로 나누어 지는 거죠. 그런데 a값을 가져온 직후에 다른 스레드가 a의 값을 변화시킨다면 어떻게 될까요? 이런 거 말로 설명하면 설명하는 사람도 읽는 사람도 불통에 빠지게 됩니다. 현재 sum이라는 변수의 값이 5라고 가정하고 간단하게 표를 통해 설명 해보죠 ㅋ

 증가 스레드(sum += 1 수행) 감소 스레드(sum -= 1 수행) 
   5를 읽어옴
 5를 읽어옴  
 1증가시킴  
 6을 저장  
  1감소시킴 
  4를 저장 

위와 같은 순서로 수행이 된다면?

분명히 1증가와 1감소가 수행되었지만, 결과는 1감소만 수행된 셈이죠. 이런식으로 서로 방해와 방해를 하다보면 이상한 결과가 나오게 되는 겁니다. 그래서 화장실에 락을 걸어서 순서를 정해서 사용하도록 하듯이, 순서를 정해줘야 하는 겁니다. 하하하하 :)

- 모니터로 락을 걸자! 락을 걸자!

자 그럼 아주 간단하게, 모니터로 락을 걸어 볼까요? 준비물은 여러분이 지금 보고 있는 모니터를 사용하면 됩니....는 훼이크고 Monitor라는 클래스를 이용해서 락을 걸어보겠습니다. Enter와 Exit라는 메서드를 이용해서 락을 걸고 빠져나가도록 통제를 하게 되는 데요. 참고로 이렇게 둘 이상의 스레드가 동시에 접근하면 안되기 때문에 락을 걸어야 하는 부분을 임계여역(Critical Section)이라고 합니다. 허허허허. 그럼 Monitor를 사용한 예제를 한번 보시죰.

using System;

using System.Threading.Tasks;

using System.Threading;

 

namespace Exam23

{

    class Program

    {

        readonly static object sync = new object();

        static readonly int count = 10000000;

        static int sum = 0;

 

        static void IncreaseByOne()

        {

            for (int i = 0; i < count; i++)

            {

                bool locked = false;

                Monitor.Enter(sync, ref locked);

                try

                {

                    sum += 1;

                }

                finally

                {

                    if (locked)

                    {

                        Monitor.Exit(sync);

                    }

                }

            }

        }

 

        static void Main(string[] args)

        {

            Task task = Task.Factory.StartNew(IncreaseByOne);

 

            for (int i = 0; i < count; i++)

            {

                bool locked = false;

                Monitor.Enter(sync, ref locked);

                try

                {

                    sum -= 1;

                }

                finally

                {

                    if (locked)

                    {

                        Monitor.Exit(sync);

                    }

                }

            }

 

            task.Wait();

            Console.WriteLine("Result = {0}", sum);

        }

    }

}

자, 코드를 보면 락이 걸려있는지를 판별하기 위해서 readonly static으로 sync라는 변수를 선언하고, 그 변수에 대해서 락을 걸로 해제하는 것으로 특정영역에 대한 락을 걸도록 했습니다. 그리고 Monitor를 통해서 들어가고 나오는 순서를 정해주고 있죠. 만약에 주 스레드에서 sync에 대해서 락을 획득하고 sum을 감소시키는 중이라면, 다른 스레드는 sync에 대한 락이 해제 될 때까지 임계영역에 들어가지 못하고 대기하게 됩니다. 그렇다면? 각자가 원하는 작업을 하는 동안 다른 스레드가 방해를 하지 못하겠죵?

참고로 락을 걸고 해제하는 대상이 되는 sync라는 객체를 보면, readonly이므로 객체의 값을 변경시킬 수 없는 immutable한 객체이고, static이므로 유일하게 존재하게 됩니다. 이렇게 복사본이 없고, 값을 변경시킬 수 없는 객체에 대해서 락을 거는 것이 안전한 방법이지요. 위 코드를 실행하면 다음과 같은 결과가 나오게 됩니다.

Result = 0

계속하려면 아무 키나 누르십시오...

이거시 바로 우리가 기대했던 결과지요. 하하하하하하하하. 아... 좀 불편한 자세로 포스팅을 하고 있으려니 허리가..... 일단 오늘은 여기까지 하겠슴니다 하하하하 :)

RigidBody의 Restitution, Friction, Damping

물리 2012. 2. 6. 00:40 Posted by 알 수 없는 사용자

안녕하세요. 이번에는 RigidBody의 속성인 restitution, friction, damping에 대해서 간략하게 설명 드리겠습니다.

1) Restitution
Restitution은 복원력 또는 탄성계수와 같은 의미로 생각하시면 됩니다.
두물체가 충돌했을때 발생하며, 범위는 [0 - 1]입니다. 1은 얌체공과 같은 완전 탄성체를 의미하고, 0은 탄성이 전혀없는 물체를 나타냅니다.(Bullet에서 default값은 0입니다.)
A물체와 B물체가 충돌했을때, A와B의 Restitution은 두 물체 Restitution의 곱으로 결정 됩니다.
아래의 그림처럼 Restitution의 값을 바꾸어 가며 테스트 해보시면 됩니다.



2) Friction
Friction은 마찰력입니다.
접촉 되어 있는 두 물체 사이에 발생하며, 범위는 [0 - 1]입니다. 1은 두 물체사이의 미끄러짐이 전혀 없는 상태이고, 0은 얼음위에서 미끄러지는 것과 같은 상태입니다.(Default는 0.5입니다.)
A와B 사이의 Friction은 역시, 두 물체의 Friction의 곱으로 결정됩니다.
아래의 그림처럼 Friction의 값을 바꾸어 가며 테스트 해보시면 됩니다.



3) Damping
Damping은 물체가 움직일때, 그 속도에 대한 대기(공기, 물, 등등)의 저항력을 의미 합니다.
예를 들면, 물위에서 공을 던지면 잘 날라가지만, 물속에서는 조금 날아가다가 속도가 0이 됩니다. 이것은 공기의 Damping은 작고 물의 Damping이 크기 때문에 나타나는 현상입니다.
Damping의 범위는 [0 - 1]입니다. 0은 저항력이 전혀 없는 상태이고, 1는 저항력이 너무커서 움직이지 못하는 상태입니다.
아래의 그림처럼 Damping의 값을 바꾸어 가며 테스트 해보시면 됩니다.(Linear와 Angular로 나누어져 있지만 개념은 같습니다.)


아래 소스버전을 참조했습니다.


소스 빌드방법
빌드를 하기 위해서는 Bullet 2.79버전과 wxWidgets 2.8.12버전이 있어야 됩니다.


1) Bullet 빌드
Bullet 2.79는 앞의 Bullet설치 페이지를 참고 하시면 됩니다.

2) wxWidgets 빌드
(1) wxWidgets 2.8.12버전을 아래의 사이트에 다운 받습니다.
http://sourceforge.net/projects/wxwindows/files/2.8.12/wxWidgets-2.8.12.zip/download

(2)"wxWidgets-2.8.12\build\msw\wx.sln" 솔루션 열기를 합니다.(솔루션이 2005버전입니다 변환 메시지가 나오면 모두 "확인"을 누릅니다.)
Debug/Relase 모두, 아래의 그림처럼 솔루션 빌드를 합니다.(빌드 후, 오류가 있으면 다시 한번 솔루션 빌드를 합니다.)
 



3) 경로설정
(1) 소스의 압축을 푼 후, "RigidBody.sln" 솔루션 열기를 합니다.

(2) 속성창(Property Manager)을 열어 아래 그림처럼 Bullet 경로를  자신의 경로로 설정함.


(3) 속성창(Property Manager)을 열어 아래 그림처럼 wxWidgets 경로를  자신의 경로로 설정함.

4) 소스 실행

'물리' 카테고리의 다른 글

Inertia Tensor(2)  (0) 2012.02.19
Inertia Tensor(1)  (0) 2012.02.13
[Bullet Physics] RigidBody 만들기  (2) 2012.01.05
[Bullet Physics] Bullet 물리엔진의 설치  (2) 2011.12.22

샘플파일: 

제6장 만화 같은 명암을 입히는 툰쉐이더

이 장에서 새로 배우는 HLSL
  • ceil() - 무조건 올림 함수



이 장에서 새로 사용하는 수학
  • 행렬합치기 - 여러 개의 행렬을 미리 곱해놓은 뒤, 그 결과를 정점변환에 사용해도 결과는 동일함. 단, 속도는 더 빠름.
  • 역행렬 - 반대 방향으로 공간변환을 할 때 유용.




배경
얼마 전에 저희 회사의 아트 디렉터가 했던 말이 있습니다. 프로그래머는 언제나 사실적인 3D 그래픽을 추구하지만, 게이머들의 보통 미적 스타일을 최대로 살린 비사실적인 그래픽에 열광한다고요. 생각해보니  맞는 말이더군요. 프로그래머들은 언제나 수학적으로 옳은 것을 추구하려고 하지만 언제나 미적 스타일을 갖춘 게임들이 흥행을 하니까요. 스트리트 파이터 4, 팀포트리스 2, 보더랜드 등이 그 좋은 예겠죠?

그 동안 현대 3D 그래픽의 주 초점도 사실적인 그래픽을 재현해 내는 것이었습니다. 하지만 그 와중에도 미적인 효과를 살리기 위한 비사실적 렌더링 기법들도 간간이 등장했는데요 여기서 살펴볼 툰쉐이딩(toon shading)[각주:1]도 그 중 하나입니다. 툰(toon)이라 하면 만화(cartoon)를 뜻합니다. 만화를 보면 명암처리를 부드럽게 하는 대신에 칼같이 딱딱 끊어서 2~3 단계로 하죠? 뭐, 만화를 안보신 분들은 없을 듯 하니 다 아시겠네요. ^^ 여기서 구현할 쉐이더가 바로 그런 일을 할 겁니다. 일단 결과를 미리 사진으로 보여드리면 대충 감이 오시겠네요.

그림. 6.1. 이장에서 만들어 볼 툰쉐이더


 
위의 사진을 잘 관찰해 봅시다. 여태까지 사용했던 평범한(?) 난반사광 쉐이더와 뭐가 다르죠? 난반사광이 부드럽게 어두워지는 대신 단계적으로 팍팍 줄어든다는 거죠? 마치 계단을 걸어 내려가는 것처럼요. 그렇다면 이것을 그래프로 그려보면 어떨까요? 일반적인 난반사광의 그래프와 비교해서 보면 좀 더 이해가 쉽겠네요.

그림 6.2 일반 난반사광 그래프와 툰쉐이딩 그래프



위 그래프를 보니 감이 팍팍 오지 않나요? 아니라고요? 으음... 그럼 위 그래프를 표로 간단하게 정리해 보겠습니다..

 난반사광의 양  툰쉐이더 값
 0  0
 0 ~ 0.2  0.2 
 0.2 ~ 0.4  0.4 
 0.4 ~ 0.6  0.6 
 0.6 ~ 0.8  0.8 
 0.8 ~ 1  1 
표 6.1 난반사광의 양과 툰쉐이더 값의 비교

이렇게 비교를 하니 정말 쉽네요. 난반사광의 값을 가져다가 0.2단위로 무조건 올림을 하면 툰쉐이더 값이 나오는군요? 그럼 이 정도만 알면 툰쉐이더를 만드는 건 식은 죽 먹기일 듯 합니다. 곧바로 렌더몽키로 가 볼까요?

기초설정
렌더몽키를 실행한 뒤, DirectX 이펙트를 추가합니다. 새로 생긴 이펙트의 이름을 Default_DirectX_Effect에서 ToonShader로 바꿉니다. matViewProjection이란 행렬이 정의되어 있는 것도 보이시죠? 삭제해 주세요.

그림 6.1에서 주전자 모델을 보여드렸었죠? 주전자는 3D 그래픽 논문에서 즐겨 사용하는 모델 중에 하나입니다. 이리저리 다양한 굴곡이 많아서 쉐이더의 결과를 딱 한눈에 살펴보기 좋다나요? 저희도 주전자 모델을 사용하겠습니다. 렌더몽키의 작업공간 패널에서 Model을 찾으세요. 이 위에 마우스 오른쪽 버튼을 누른 뒤, Change Model > Teapot.3ds를 선택합니다.

툰쉐이딩을 하려면 일단 난반사광을 계산해야겠죠? 그래야 그 결과를 0.2 단위로 올림할 수 있으니까요. 그렇다면 '제4장: 기초적인 조명쉐이더'에서 그랬던 것처럼 빛의 위치와 정점의 법선정보가 필요하겠군요. 우선 빛의 위치를 변수로 선언하겠습니다. ToonShader에 오른쪽 마우스 버튼을 누른 뒤, Add Variable > Float > Float4를 선택하고, 변수의 이름을 gWorldLightPosition으로 바꿉니다. 이 변수의 값은 예전과 마찬가지로 (500, 500, -500, 1)으로 맞춰주세요. 다음은 정점에서 법선정보를 읽어올 차례입니다. Stream Mapping을 더블클릭해서 NORMAL 필드를 추가하면 되겠죠? 데이터형은  FLOAT3, Index는 0으로 해주는 것도 잊지 마세요.

그림 6.1을 다시 한번 봐 보죠. 주전자가 녹색이죠? 주전자의 색을 지정해주는 방법은 여러 가지가 있지만 여기서는 전역변수 하나로 전체 표면의 색을 지정해 주겠습니다.[각주:2] ToonShader에 마우스 오른쪽 버튼을 누른 뒤, Add Variable > Float > Float3를 선택합니다. 새로 변수가 생기면 이름을 gSurfaceColor로 바꿉니다. 이제 이 변수 위에 마우스를 더블클릭하여 값을 (0, 1, 0)으로 변경합니다. 쉐이더에서 색상을 0~1 사이의 백분율 값으로 표현한다는 거 잊지 않으셨죠?

이제 행렬들을 좀 추가해 주겠습니다. 여태까지 다뤘던 쉐이더 기법에서는 기초설정을 할 때마다 월드행렬, 뷰행렬, 투영행렬을 따로 정의해 줬었죠? 여기서는 조금 다른 방법을 사용하도록 하죠. 3D 그래픽에서 공간변환을 할 때, 행렬을 사용하는 이유 중 하나가 여러 행렬들을 미리 합쳐놓으면(concatenation) 불필요한 연산을 줄일 수 있기 때문입니다. 예를 들면, 정점의 위치를 공간변환 할 때 월드행렬, 뷰행렬, 투영행렬을 차례대로 곱해줘야 하죠? 이러지 말고 미리 월드행렬, 뷰행렬, 투영행렬을 곱해서 새로운 행렬을 하나 구해 놓은 뒤, 그 행렬을 정점에 곱해도 결과는 동일합니다. 그러나 성능상으로 보면 행렬을 3번 곱하는 것보다 1번 곱하는 게 당연히 빠를 테니 행렬을 미리 합치는 방법이 더 낫지요.

여기서도 미리 행렬을 합치겠습니다. 그럼 그 결과 행렬을 건네 받을 전역변수를 하나 추해야겠죠? ToonShader에 오른쪽 마우스 버튼을 눌러 Add Variable > Matrix > Float(4x4)를 선택한 뒤, 변수명을 gWorldViewProjectionMatrix로 바꿉니다. 이제 이 변수 위에 마우스 오른쪽 버튼을 눌러 Variable Semantic > WorldViewProjection을 선택합니다.

자, 그럼 이렇게 행렬을 한 번만 곱하는 건 좋은데 난반사광을 계산하려면 월드행렬이 필요했던 것 같은데요? 빛의 위치가 월드공간에 정의되어 있으니까 빛의 방향벡터를 만들려면 월드공간에서의 정점위치가 필요했었네요. 그럼 당연히 월드행렬을 곱해야겠죠. 그리고 난반사광을 구하려면 역시 월드공간에서의 정점법선도 필요했었으니 역시 월드행렬이 필요하군요. 그렇다면 월드행렬을 전역변수로 전달해줘서 이렇게 행렬곱을 두 번 더 해줘야 할까요? 뭐 그러셔도 상관 없습니다. 틀린 방법은 아니거든요. 하지만 조금만 생각을 더 해보면 행렬곱 1번만으로 똑같은 일을 할 수 있습니다.

정점의 위치와 법선을 월드공간으로 변환하는 이유는 빛의 위치가 월드공간에 정의되어 있어서였습니다. 모든 변수가 동일한 공간에 있어야만 올바른 결과를 구할 수 있으니까요. 그럼 정점의 위치와 법선벡터를 월드공간으로 변환하는 대신에 빛의 위치를 지역공간으로 변환해버리면 어떨까요? 그러면 정점의 위치와 법선에 손을 대지 않아도 모든 매개변수들이 동일한 공간에 있겠죠? 이 방법은 행렬을 1번만 곱하니 아무래도 조금 더 빠르겠네요.

그렇다면 월드공간을 물체공간으로 어떻게 변환할까요? 월드행렬의 역행렬(inverse matrix)을 곱하면 됩니다. 그럼 렌더몽키에 월드행렬의 역행렬도 추가해 보도록 하죠. ToonShader에 마우스 오른쪽 버튼을 누른 뒤, Add Variable > Matrix > Float(4x4)를 선택합니다. 이제 이 변수 이름을 gInvWorldMatrix로 바꿔 주세요. 마지막으로 변수 위에 마우스 오른쪽 버튼을 눌러 Variable Semantic > WorldInverse를 누르면 모든 설정이 마무리 되었습니다.

그림 6.3. 기초설정을 마친 렌더몽키 프로젝트




정점쉐이더
일단 전체 소스코드부터 보여드린 뒤, 한 줄씩 차근차근 설명해드리겠습니다.

struct VS_INPUT 
{
   float4 mPosition : POSITION;
   float3 mNormal: NORMAL;   
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;   
};

float4x4 gWorldViewProjectionMatrix;
float4x4 gInvWorldMatrix;

float4 gWorldLightPosition;

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldViewProjectionMatrix );
   
   float3 objectLightPosition = mul( gWorldLightPosition, gInvWorldMatrix);
   float3 lightDir = normalize(Input.mPosition.xyz - objectLightPosition);
   
   Output.mDiffuse = dot(-lightDir, normalize(Input.mNormal));
   
   return( Output );
   
}




정점쉐이더 입출력데이터 및 전역변수
조명(난반사광)을 계산하려면 법선이 필요하죠? 따라서 정점쉐이더 입력데이터로 위치와 법선이 필요합니다.[각주:3]

struct VS_INPUT 
{
   float4 mPosition : POSITION;
   float3 mNormal: NORMAL;   
};


정점출력데이터도 별로 어렵지 않습니다. 난반사광을 계산한 뒤, 픽셀쉐이더에 전달해 주는 게 전부입니다.[각주:4] 이게 잘 이해가 안 되시는 분들은 '제4장: 기초적인 조명쉐이더'를 다시 한 번 읽어 주세요.

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;   
};


이제 전역변수로는 위에서 설명 드렸던 행렬 2개와 광원의 위치를 선언해야겠네요.

float4x4 gWorldViewProjectionMatrix;
float4x4 gInvWorldMatrix;

float4 gWorldLightPosition;


이러면 앞서 렌더몽키 프로젝트에 더했던 변수들을 다 처리한 거 같죠? 이제 정점쉐이더 함수를 보겠습니다.

정점쉐이더 함수
우선 정점쉐이더의 가장 중요한 임무를 수행하겠습니다. 정점의 위치를 투영공간으로 가져옵니다. 월드행렬, 뷰행렬, 투영행렬을 하나로 미리 합쳐버렸으니 코드 한 줄로 이런 일을 할 수 있겠네요.

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldViewProjectionMatrix );



이제 난반사광의 양을 계산할 차례입니다. 앞서 말씀 드렸듯이 빛의 위치를 지역공간으로 변환한 뒤, 모든 계산을 이 공간에서 해보겠습니다. 우선 빛의 위치를 지역공간으로 변환합니다.

   float3 objectLightPosition = mul( gWorldLightPosition, gInvWorldMatrix);


이제 광원의 위치에서 현재 위치(이미 지역공간에 있습니다)를 가리키는 방향벡터를 만듭니다. 이 방향벡터의 길이를 1로 만드는 것도 잊지 말아야겠죠?

   float3 lightDir = normalize(Input.mPosition.xyz - objectLightPosition);


이제 그 결과와 정점의 법선(역시 지역공간에 존재합니다) 간의 내적을 구하면 난반사광의 양을 구할 수 있습니다.

   Output.mDiffuse = dot(-lightDir, normalize(Input.mNormal));


위에서 법선의 길이를 1로 만들기 위해 normalize()함수를 호출한 거 보이시죠? 보통 정점버퍼로부터 곧바로 가져온 법선은 이미 정규화가 되어있는 게 보통이나 혹시나 해서 normalize()를 한 번 더 호출해 봤답니다.

이제 가볍게 Output을 반환합니다.

   return( Output );
   
}


정점쉐이더 함수는 별로 어려운 게 없었습니다. '제4장: 기초적인 조명쉐이더'에서 다 배웠던 내용이니까요. 그냥 다른 공간을 사용했다는 게 좀 다른 내용이지만 그리 어렵지 않게 이해하시리라 믿습니다. 이제 픽셀쉐이더를 살펴보겠습니다.

픽셀쉐이더
정점쉐이더에서와 마찬가지로 전체 소스코드부터 보여드립니다.

float3 gSurfaceColor;

struct PS_INPUT 
{
   float3 mDiffuse : TEXCOORD1;
};

float4 ps_main(PS_INPUT Input) : COLOR
{  
   float3 diffuse = saturate(Input.mDiffuse);
   
   diffuse = ceil(diffuse * 5) / 5.0f;
   
   return float4( gSurfaceColor * diffuse.xyz, 1);
   
}


우선 전역변수와 픽셀쉐이더 입력데이터를 정의하죠. 표면의 색상을 전역변수로 선언하고 정점쉐이더에서 계산을 마친 난반사광의 양을 입력데이터로 받겠습니다.

float3 gSurfaceColor;

struct PS_INPUT 
{
   float3 mDiffuse : TEXCOORD1;
};


이제 픽셀쉐이더 함수를 봅시다. 우선 mDiffuse에서 저희에게 별 의미가 없는 0 이하의 값을 잘라 냅니다.

float4 ps_main(PS_INPUT Input) : COLOR
{  
   float3 diffuse = saturate(Input.mDiffuse);


이제 이 값을 0.2단위로 딱딱 잘라야겠네요. 0.2단위로 무조건 올림을 하면 된다고 했었죠? HLSL에서 무조건 올림을 하는 함수는 ceil()입니다.[각주:5] 근데 ceil() 함수는 언제나 바로 위의 정수로만 올림을 한다는군요. 저희는 0.2단위로 올림을 해야 하는데 어쩌죠? 다음과 같이 간단히 곱셈과 나눗셈을 하면 됩니다.

   diffuse = ceil(diffuse * 5) / 5.0f;


위 공식(?)을 자세히 살펴보죠. diffuse가 0~1 사이의 값이니 여기에 5를 곱하면 범위가 0~5가 될 것입니다. 여기에 ceil()을 적용하면 그 결과값이 0, 1, 2, 3, 4, 5중에 하나가 되겠죠. 이제 이 값을 5로 나누면 최종 결과값이 0, 0.2, 0.4, 0.6, 0.8, 1 중에 하나가 될 겁니다. 이게 바로 저희가 원하는 값 맞죠? 그림 6.2와 표 6.1를 다시 봐도 이 값이 맞네요.

그럼 이제 표면의 색을 곱하기만 하면 끝입니다.[각주:6]

   return float4( gSurfaceColor * diffuse.xyz, 1);
}


이제 F5를 눌러 정점쉐이더와 픽셀쉐이더를 각각 컴파일 한 뒤, 미리 보기 창을 보시면 그림 6.1와 똑같은 주전자가 보이죠? 아, 배경색이 다르다고요? 미리 보기 창 안에서 마우스 오른쪽 버튼을 누른 뒤 Clear Color를 선택하시면 배경색을 바꿀 수 있습니다.

선택사항: DirectX 프레임워크
이제 C++로 작성한 DirectX 프레임워크에서 쉐이더를 사용하시고자 하는 분들을 위한 선택적인 절입니다.

우선 '제3장: 텍스처매핑'에서 만들었던 프레임워크의 사본을 만들어 새로운 폴더에 저장합니다. 그 다음은 렌더몽키에서 사용했던 쉐이더와 3D 모델을 DirectX 프레임워크에서 사용할 수 있도록 파일로 저장할 차례입니다.

  1. Workspace 패널에서 ToonShader를 찾아 오른쪽 마우스 버튼을 누릅니다.
  2. 팝업메뉴에서 Export > FX Exporter를 선택합니다.
  3. 위에서 새로 만든 폴더를 찾아 그 안에 ToonShader.fx란 이름으로 파일을 저장합니다.
  4. 이제 Worspace 패널에서 Model을 찾아 오른쪽 마우스 버튼을 누릅니다.
  5. 팝업메뉴에서 Save > Geometry Saver를 선택합니다.
  6. 위에서 새로 만든 폴더를 찾아 그 안에 Teapot.x란 이름으로 파일을 저장합니다.

이제 비주얼 C++ 에서 프레임워크의 솔루션 파일을 엽니다.

우선 예전에 있던 전역변수들부터 살펴보겠습니다. gpTextureMappingShader 변수가 있는데 이 변수가 언급되어 있는 곳을 모두 찾아 gpToonShader로 바꿉니다. gpSphere변수도 똑같은 방법으로 모두 gpTeapot으로 바꿔 줍니다. 텍스처 변수, gpEarthDM도 있네요. 여기서는 텍스처를 전혀 사용하지 않으니 gpEarthDM변수를 사용하는 코드를 모두 찾아 삭제해주세요.

이제 새로 추가해야 할 전역변수들을 알아볼까요? 새로 추가해야 할 전역변수는 빛의 위치와 표면의 색상밖에 없는 것 같군요. 다음의 코드를 추가합니다.

// 광원의 위치
D3DXVECTOR4 gWorldLightPosition = D3DXVECTOR4(500.0f, 500.0f, -500.0f, 1.0f);

// 표면의 색상
D3DXVECTOR4 gSurfaceColor =       D3DXVECTOR4(0, 1, 0, 1);


위 코드에서 전역변수를 선언할 때 렌더몽키에서 사용했었던 값도 그대로 대입해줬습니다. 

이제 LoadAssets() 함수로 가서 로딩해올 쉐이더와 모델의 이름을 각각 Toonshader.fx와 Teapot.x로 바꿔줍니다.

bool LoadAssets()
{
    // 텍스처 로딩

    // 쉐이더 로딩
    gpToonShader = LoadShader("ToonShader.fx");
    if ( !gpToonShader )
    {
        return false;
    }

    // 모델 로딩
    gpTeapot = LoadModel("Teapot.x");
    if ( !gpTeapot )
    {
        return false;
    }

    return true;
}


다음은 실제로 장면을 그리는 RenderScene() 함수입니다. 행렬 2개를 새로 전달해줘야 했었죠? 월드/뷰/투영행렬을 합친 행렬과 월드행렬의 역행렬이었습니다. 우선 월드행렬의 역행렬을 구해봅시다. 월드행렬을 구했던 코드 아래에 다음의 라인을 추가합니다.

    // 월드행렬의 역행렬을 구한다.
    D3DXMATRIXA16 matInvWorld;
    D3DXMatrixTranspose(&matInvWorld, &matWorld);


위 코드에서 사용한 D3DXMatrixTranspose() 함수는 전치행렬(transpose matrix)을 구합니다. 여기서 역행렬 대신에 전치행렬을 구한 이유는 월드행렬이 직교행렬이기 때문이죠. 직교행렬의 전치행렬은 역행렬과 같습니다.[각주:7]

이제 월드/뷰/투영행렬을 서로 곱할 차례입니다. D3DXMatrixMultiply() 함수를 사용하겠습니다.

    // 월드/뷰/투영행렬을 미리 곱한다.
    D3DXMATRIXA16 matWorldView;
    D3DXMATRIXA16 matWorldViewProjection;
    D3DXMatrixMultiply(&matWorldView, &matWorld, &matView);
    D3DXMatrixMultiply(&matWorldViewProjection, &matWorldView, &matProjection);


월드행렬 X 뷰행렬 X 투영행렬 순으로 곱해준 거 보이시죠?

이제 위에서 만들었던 두 행렬을 쉐이더에 전달해 주겠습니다. 예전에 사용했던 SetMatrix() 함수 호출들을 다 지우시고 아래의 코드를 대신 삽입해주세요.


    // 쉐이더 전역변수들을 설정
    gpToonShader->SetMatrix("gWorldViewProjectionMatrix",
        &matWorldViewProjection);
    gpToonShader->SetMatrix("gInvWorldMatrix", &matInvWorld);


마지막으로 광원의 위치와 표면의 색상을 전달해주는 것도 잊지 마셔야겠죠?

    gpToonShader->SetVector("gWorldLightPosition", &gWorldLightPosition);
    gpToonShader->SetVector("gSurfaceColor", &gSurfaceColor);


이제 코드를 컴파일 한 뒤, 프로그램을 실행하시면 빙글빙글 도는 주전자를 보실 수 있을 겁니다. 아무래도 회전을 하니까 손잡이나 주둥이에서 툰쉐이더 효과가 더 잘 나타나죠?

정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.
  • 툰쉐이더는 비실사 렌더링 기법 중에 하나이다.
  • 툰쉐이딩은 난반사광을 단계적으로 감소시키는 것에 지나지 않는다.
  • 행렬들을 미리 곱해놓으면 공간변환을 더 빨리 할 수 있다.



p.s. 여기는 댓글 달릴때마다 이메일이 오지 않아서 답변을 빨리빨리 드리기가 쉽지 않군요. 제 개인 블로그에 포스팅 된 버전에 댓글을 다시는게 제일 답변이 빠릅니다. 오탈자 및 잘못된 내용 수정도 제일 빠릅니다.


포프 올림
  1. 셀 쉐이딩(cell shading)이라고도 합니다. [본문으로]
  2. 만약 한 표면 위에서 여러 가지 색상을 사용하시고 싶으시다면 3DS MAX에서 메쉬를 만드실 때, 정점색상(vertex color)를 칠하셔도 됩니다. 그리고 정점쉐이더 입력데이터에서 COLOR0이나 COLOR1 시맨틱을 사용하시면 이 정점정보를 읽어올 수 있습니다. [본문으로]
  3. 정점마다 색을 지정해주셨다면 float3 mColor : COLOR0; 도 추가하셔야 합니다. Stream Mapping에서도 COLOR0을 더해주는 거 잊지 마세요. [본문으로]
  4. 마찬가지로 정점마다 색을 지정해주셨다면 float3 mColor: COLOR0;도 추가하셔야 합니다. [본문으로]
  5. 영어로 ceiling이 '천장'이라는 뜻이니까, ceil을 '천장으로 올리다' 정도로 생각하시면 이해에 도움이 되실 겁니다. 이 반대로 무조건 내림을 하는 함수로 floor()입니다. 이것은 '바닥으로 내리다' 정도로 이해하세요. [본문으로]
  6. 빛의 색상은 흰색(1, 1, 1)이라고 가정했습니다. 어떤 수에 1을 곱해도 결과는 바뀌지 않는 거 아시죠? [본문으로]
  7. 이에 대한 증명 및 자세한 설명은 이미 시중에 나와있는 훌륭한 수학책을 참고하시기 바랍니다. 참고로 순수하게 역행렬을 구하시려 한다면 D3DXMatrixInverse() 함수를 쓰시면 됩니다. [본문으로]


픽셀쉐이더
픽셀쉐이더도 전체 소스코드를 보여드린 뒤, 새로 추가된 내용만 설명 드립니다.

struct PS_INPUT
{
   float2 mUV : TEXCOORD0;
   float3 mDiffuse : TEXCOORD1;
   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;
};

sampler2D DiffuseSampler;
sampler2D SpecularSampler;

float3 gLightColor;

float4 ps_main(PS_INPUT Input) : COLOR
{
   float4 albedo = tex2D(DiffuseSampler, Input.mUV);
   float3 diffuse = gLightColor * albedo.rgb * saturate(Input.mDiffuse);
   
   float3 reflection = normalize(Input.mReflection);
   float3 viewDir = normalize(Input.mViewDir); 
   float3 specular = 0;
   if ( diffuse.x > 0 )
   {
      specular = saturate(dot(reflection, -viewDir ));
      specular = pow(specular, 20.0f);
      
      float4 specularIntensity  = tex2D(SpecularSampler, Input.mUV);
      specular *= specularIntensity.rgb * gLightColor;
   }

   float3 ambient = float3(0.1f, 0.1f, 0.1f) * albedo;
   
   return float4(ambient + diffuse + specular, 1);
}


우선 새로 렌더몽키에 추가했던 세 변수를 전역적으로 선언하겠습니다.

sampler2D DiffuseSampler;
sampler2D SpecularSampler;

float3 gLightColor;


그리고 PS_INPUT구조체에 UV좌표를 추가합니다.

  float2 mUV : TEXCOORD0;


이제 픽셀쉐이더 함수의 젤 윗줄에서 디퓨즈맵을 샘플링 해보죠. 

   float4 albedo = tex2D(DiffuseSampler, Input.mUV);


이것이 바로 현재 픽셀이 반사하는 색깔입니다. 여기에 난반사광의 양과 빛의 색상을 곱해야 한다고 했죠? 이전에 diffuse변수를 구하던 코드를 다음과 같이 바꿉니다.

   float3 diffuse = gLightColor * albedo.rgb * saturate(Input.mDiffuse);


이제 한번 F5를 눌러서 정점쉐이더와 픽셀쉐이더를 각각 컴파일 한 뒤, 미리 보기 창을 한 번 봐볼까요?

그림 5.3. 디퓨즈맵만 적용한 결과

 
담벽 텍스처가 보이죠? 푸르스름한 빛도 보이네요. 근데 스페큘러맵을 아직 쓰지 않아서인지 정 반사광이 너무 강하네요. 돌 사이의 틈새까지도 말이지요! 그럼 스페큘러맵을 더해보도록 하죠.

팁: 미리 보기 창에서 물체를 회전하는 법
그림 5.3 처럼 틈새에 빛이 들어오게 하려면 물체를 회전하셔야 할 겁니다. 미리 보기 창에서 물체를 회전하시려면 창 안에 왼쪽 마우스 버튼을 누른 채 마우스를 이리저리 움직여보세요. 만약 회전 대신에 이동이나 확대/축소가 된다면 툴바에서 오른쪽 두 번째 아이콘(Overloaded Camera Mode)을 눌러주시면 됩니다.



픽셀쉐이더에서 specular 변수의 거듭제곱을 구하는 코드(pow함수 호출) 바로 밑에서 스페큘러맵을 샘플링 합니다.

      float4 specularIntensity  = tex2D(SpecularSampler, Input.mUV);


이제 이것과 빛의 색상을 specular에 곱해야겠죠? 코드는 다음과 같습니다.

      float4 specularIntensity  = tex2D(SpecularSampler, Input.mUV);
      specular *= specularIntensity.rgb * gLightColor;


위 그림에서 또 다른 문제점 하나는 난 반사광이 사라지는 순간부터 디퓨즈 텍스처의 디테일도 사라진다는 것입니다. 이것은 주변광 값으로 (0.1, 0.1, 0.1)만을 사용했기 때문인데요. 주변광은 그냥 저희가 임의로 정한 빛의 양이므로 여기에도 디퓨즈맵을 곱해주는 것이 맞습니다. ambient변수를 구하는 코드를 찾아 다음과 같이 바꿔주세요.

float3 ambient = float3(0.1f, 0.1f, 0.1f) * albedo;


다시 한번 정점쉐이더와 픽셀쉐이더를 컴파일 한 뒤 미리 보기 창을 볼까요?

그림 5.4. 스페큘러맵과 주변광까지 제대로 적용한 결과

 
확실한 차이를 볼 수 있죠? 정 반사광이 그렇게 강하지도 않고, 틈새도 완벽하네요. 거기다가 어두운 픽셀에서도 디퓨즈맵의 흔적을 찾아 볼 수 있네요.


선택사항: DirectX 프레임워크
이제 C++로 작성한 DirectX 프레임워크에서 쉐이더를 사용하시려는 분들을 위한 선택적인 절입니다.

우선 저번 장에서 사용했던 프레임워크의 사본을 만들어 새로운 폴더에 저장합니다. 그 다음, 렌더몽키에서 사용했던 쉐이더와 3D 모델을 DirectX 프레임워크에서 사용할 수 있도록 파일로 저장합니다. Sphere.x와 SpecularMapping.fx라는 파일이름을 사용하도록 하겠습니다. 또한 렌더몽키에서 사용했었던 텍스처 2개를 복사해다가 프레임워크 폴더에 저장합니다. Fieldstone_DM.tga와 Fieldstone_SM.tga로 이름을 사용하지요.

이제 비주얼 C++ 솔루션 파일을 연 뒤, 소스코드에서 gpLightingShader 변수가 언급된 곳을 모두 찾아gpSpecularMappingShader로 변경합니다.

그 다음, 전역변수 섹션에 가서 두 텍스처를 저장할 포인터들과 빛의 색상을 저장하는 변수를 선언합니다.

// 텍스처
LPDIRECT3DTEXTURE9        gpStoneDM    = NULL;
LPDIRECT3DTEXTURE9        gpStoneSM    = NULL;

// 빛의 색상
D3DXVECTOR4               gLightColor(0.7f, 0.7f, 1.0f, 1.0f);


렌더몽키에서 사용했던 푸르스름한 빛의 값인 (0.7, 0.7, 1.0)을 그대로 사용하는 거 보이시죠? 위에서 새로운 D3D 자원(텍스처)을 둘 선언했으니 이들을 해제하는 코드를 추가하도록 하죠. CleanUp() 함수에 다음의 코드를 추가합니다.

    // 텍스처를 release 한다.
    if ( gpStoneDM )
    {
        gpStoneDM->Release();
        gpStoneDM = NULL;
    }

    if ( gpStoneSM )
    {
        gpStoneSM->Release();
        gpStoneSM = NULL;
    }


이제 D3D자원들을 로딩할 차례입니다. LoadAssets() 함수에 다음의 코드를 더합니다.

    // 텍스처 로딩
    gpStoneDM = LoadTexture("Fieldstone_DM.tga");
    if ( !gpStoneDM )
    {
        return false;
    }

    gpStoneSM = LoadTexture("Fieldstone_SM.tga");
    if ( !gpStoneSM )
    {
        return false;
    }


쉐이더 파일의 이름을 SpecularMapping.fx로 바꿔주는 것도 잊지 맙시다.

    gpSpecularMappingShader = LoadShader("SpecularMapping.fx");


이제 마지막으로 RenderScene() 함수를 봅시다. 이미 쉐이더가 모든 일을 하고 있으니 간단히 새로운 변수들을 대입해주기만 하면 되겠네요. 예전에 SetMatrix() 함수들을 호출 해주던 곳이 있었죠? 그 아래에 다음의 코드를 추가합시다.

    gpSpecularMappingShader->SetVector("gLightColor", &gLightColor);
    gpSpecularMappingShader->SetTexture("DiffuseMap_Tex", gpStoneDM);
    gpSpecularMappingShader->SetTexture("SpecularMap_Tex", gpStoneSM);


위 코드는 빛의 색상과 두 텍스처맵을 쉐이더에 전달해 줍니다. 텍스처맵을 대입해 줄 때 _Tex 접미사를 붙여줘야 한다는 건 '제3장: 텍스처매핑'에서 설명했었죠?

이제 코드를 컴파일 한 뒤, 프로그램을 실행하면 렌더몽키에서와 동일한 결과를 보실 수 있을 겁니다.

이쯤 되면 느끼셨겠지만 쉐이더를 사용하면 DirectX 프레임워크에서 책임져야 할 그래픽 관련 업무가 현저히 줄어듭니다. D3D 자원을 로딩하는 것과 쉐이더 매개변수, 그리고 렌더상태(render state)를 관리하는 것이 거의 전부입니다.

정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.
  • 인간이 물체의 색을 볼 수 있는 이유는 물체마다 흡수 및 반사하는 빛의 스펙트럼이 다르기 때문이다.
  • 3D 그래픽에서는 디퓨즈맵과 스페큘러맵을 사용하여 빛의 흡수 및 반사를 재현한다.
  • 스페큘러맵은 각 픽셀의 정 반사광을 조절하는 것을 주 목적으로 한다.
  • 빛의 색도 물체의 최종 색에 기여한다.
  • 텍스처는 색상정보만을 저장하는 것이 아니다. 스페큘러맵의 경우가 그 예이다.

이 장은 예상보다 매우 쉽게 끝나버렸습니다. 저번 장에서 작성했던 조명쉐이더에 살을 붙였기 때문인데요. 이렇게 기본이 되는 쉐이더를 잘 짜놓으면 매우 쉽게 구현할 수 있는 기법들이 많습니다.

하지만 이번 장에서 배운 내용을 절대 가볍게 보시지 말기 바랍니다. 최근 게임에서 3D 물체를 렌더링 할 때 여기서 배웠던 기법을 법선매핑[각주:1]과 혼합하는 것이 거의 표준입니다.



p.s. 여기는 댓글 달릴때마다 이메일이 오지 않아서 답변을 빨리빨리 드리기가 쉽지 않군요. 제 개인 블로그에 포스팅 된 버전에 댓글을 다시는게 제일 답변이 빠릅니다. 오탈자 및 잘못된 내용 수정도 제일 빠릅니다.


포프 올림
  1. 법선매핑(normal mapping)은 제7장에서 배웁니다. [본문으로]
샘플파일 받기:




제5장 물체에 색을 입히는 디퓨즈/스페큘러 매핑

실 세계에서 물체들이 다른 색을 갖는 이유는 물체마다 흡수/반사하는 빛의 스펙트럼(spectrum)이 다르기 때문입니다. 예를 들면 검정색 표면은 모든 스펙트럼을 흡수해서 검정색이고, 하얀색 표면은 모든 스펙트럼을 반사해서 하얀색입니다. 빨간색 표면 같은 경우는 빨강색의 스펙트럼을 반사하고 그 외의 스펙트럼을 흡수하니 빨간색으로 보이는 것이죠.

그렇다면 표면이 빛을 흡수하는 성질을 쉐이더에서 어떻게 표현할까요? 표면 전체가 한가지 색으로 칠해져 있다면 그냥 간단히 전역변수를 사용하면 되겠죠? 하지만 대부분의 경우, 물체의 표면은 다소 복잡한 패턴을 가지고 있습니다. 따라서 각 픽셀마다 색상을 정해줘야겠지요. 그럼 표면에서 반사할 색을 이미지로 그린 뒤, 픽셀쉐이더에서 이 텍스처를 읽어와 조명계산의 결과에 곱하면 되겠죠?

그런데 '제4장: 기초적인 조명쉐이더'에서 빛을 계산할 때, 난 반사광(diffuse light)와 정 반사광(specular light)을 따로 구했었죠? 그렇다면 난 반사광과 정 반사광을 합친 결과에 이 텍스처를 곱해야 할까요? 아니면 따로 해야 할까요? 전에도 말씀드렸듯이 인간이 물체를 지각할 수 있는 이유는 대부분 난 반사광 덕분입니다. (정 반사광은 타이트한 하이라이트를 추가해줄 뿐이지요.) 따라서 위 텍스처를 난 반사광의 결과에만 적용하는 것으로 충분합니다. 이렇게 난 반사광에 적용하는 텍스처를 디퓨즈맵(diffuse map)이라 부릅니다.

그렇다면 정 반사광은 어떻게 할까요? 물론 디퓨즈맵을 정 반사광에 그대로 사용할 수도 있습니다만 다음과 같은 두 가지 이유 때문에 정 반사광용으로 스페큘러맵(specular map)을 따로 만드는 경우가 허다합니다. 첫째, 난 반사광이 반사하는 빛과 정 반사광이 반사하는 빛의 스펙트럼이 다른 경우가 있습니다. 둘째, 각 픽셀이 반사하는 정 반사광의 정도를 조절하는 용도로 스페큘러맵을 사용할 수도 있습니다. 예를 들어, 사람의 얼굴에 정 반사광을 때린다고 생각해 보죠. '제4장: 기초적인 조명쉐이더'에서 봤던 것처럼 얼굴의 전체에서 고르게 정 반사광이 보일까요? 거울을 보시면 알겠지만 이마나 코가 더 반짝거리죠? 그리고 이마나 코에서도 정 반사광이 좀 듬성듬성 보일 겁니다. (이건 모공이나 털 때문에 피부가 매끄럽지 않기 때문입니다.) 스페큘러맵을 잘 칠하면 이런 효과를 낼 수 있습니다. 따라서 이 장에서는 디퓨즈맵과 스페큘러맵을 따로 사용하도록 하겠습니다.

이 외에도 물체의 색에 영향을 미치는 다른 요소가 있습니다. 바로 조명의 색입니다. 흰색 물체에 빨간색 빛을 비추면 물체가 불그스름하게 되죠? 조명의 색은 전역변수로 쉽게 지정할 수 있습니다.

여태까지 말씀 드린 것을 보기 쉽게 설명하면 다음과 같습니다.


난 반사광 = 빛의 색상 X 난 반사광의 양 X 디퓨즈맵의 값
정 반사광 = 빛의 색상 X 난 반사광의 양 X 스페큘러맵의 값


그러면 위 내용들을 염두에 두고 디퓨즈/스페큘러매핑 쉐이더를 작성해 볼까요?

기초설정
일단 저번 장에서 사용했던 렌더몽키 프로젝트의 사본을 만들어 새로운 폴더에 저장합니다. 혹시 렌더몽키 프로젝트를 저장해 놓지 않으신 분들은 부록 CD에서 samples\04_lighting\lighting.rfx 파일을 복사해 오시면 됩니다.

이 파일을 렌더몽키에서 연 다음, 쉐이더의 이름을 SpecularMapping으로 바꾸겠습니다. 이름까지 바꾸셨다면 이제 디퓨즈맵과 스페큘러맵으로 사용할 이미지들을 추가해야겠군요. 쉐이더 이름에 마우스 오른쪽 버튼을 누르면 나오는 팝업메뉴에서 Add Texture > Add 2D Texture > Fieldstone.tga파일을 선택합니다. 그러면 Fieldstone이라는 이름의 텍스처가 보이시죠? 이 이름을 DiffuseMap으로 바꾸도록 합시다. 

이제 Pass 0에 마우스 오른쪽 버튼을 눌러 Add Texture Object > DiffuseMap을 선택합니다. Texture0이라는 텍스처 개체가 생겼을 것입니다. 이 이름을 DiffuseSampler로 변경합니다.

이제 스페큘러맵을 추가해야 하는데 아무리 렌더몽키 폴더를 뒤져봐도 마땅한 놈이 안 보이는군요. 그래서 제 손으로 직접 스페큘러맵을 만들어서 부록 CD에 넣어놨습니다. 일단 이 스페큘러맵이 어떻게 생겼는지 한번 볼까요? 디퓨즈맵과 같이 비교해서 보면 좋겠군요

그림 5.1. 디퓨즈맵(왼쪽)과 스페큘러맵(오른쪽)


스페큘러맵에서 돌판 사이의 틈새를 검정색으로 칠해 놓은 거 보이시죠? 따라서 이 틈새는 전혀 정 반사광을 반사하지 않을 겁니다. (하지만 난 반사광은 여전히 존재하지요.) 여기서 한가지 기억하실 점은 텍스처가 언제나 색상정보를 가지지는 않는다는 것입니다. 스페큘러맵이 그 좋은 예죠. 스페큘러맵에 저장된 정보는 최종이미지의 색상 값이라기 보다는 각 픽셀이 반사하는 정 반사광의 양입니다. 이와 마찬가지로 픽셀 수준에서 제어하고 싶은 변수가 있다면 이렇게 텍스처를 사용하는 것이 보통입니다. 후에 법선매핑을 다룰 때, 이렇게 텍스처를 이용하는 예를 보실 수 있을 것입니다.

팁: 픽셀수준에서 제어하고 싶은 변수가 있다면 텍스처를 이용합니다.


자, 그럼 부록CD에서 Samples\05_DiffuseSpecularMapping\Fieldstone_SM을 찾아서 렌더몽키 프로젝트에 추가합니다. 추가하는 방법은 간단히 파일을 끌어다 이펙트 이름 위에 놓아주면 됩니다. 텍스처 이름은 SpecularMap으로, 텍스처 개체의 이름은 SpecularSampler로 하겠습니다.

다음은 빛의 색을 추가하도록 하지요. 쉐이더 이름에 오른쪽 버튼을 누른 뒤에 Add Variable > Float > Float3를 선택합니다. 변수명을 gLightColor로 하지요. 이제 이 변수를 더블클릭하면 값을 대입할 수 있습니다. 약간 푸르스름한 빛을 사용한다는 의미로 (0.7, 0.7, 1.0)을 대입하겠습니다.

마지막으로 Stream Mapping을 설정할 차례입니다. '제4장: 기초적인 조명쉐이더'와는 달리 여기서는 텍스처를 사용하니 정점데이터에서 UV 좌표를 읽어와야 합니다. Stream Mapping에 더블클릭을 해서 TEXCOORD0을 추가합니다. 당연히 데이터형은 float2입니다.

여기까지 설정을 마치셨다면 렌더몽키 작업공간이 아래와 같을 겁니다.

그림 5.2. 기초설정을 마친 렌더몽키 프로젝트

 
정점쉐이더
이제 정점쉐이더를 보도록 하지요. 우선 전체 소스코드부터 보여드린 뒤, 새로 추가된 코드만 설명 드리겠습니다.

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;

float4 gWorldLightPosition;
float4 gWorldCameraPosition;

struct VS_INPUT 
{
   float4 mPosition : POSITION;
   float3 mNormal: NORMAL;
   float2 mUV: TEXCOORD0;
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float2 mUV: TEXCOORD0;
   float3 mDiffuse : TEXCOORD1;
   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;
};

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );

   float3 lightDir = Output.mPosition.xyz - gWorldLightPosition.xyz;
   lightDir = normalize(lightDir);
   
   float3 viewDir = normalize(Output.mPosition.xyz - gWorldCameraPosition.xyz);
   Output.mViewDir = viewDir;
   
   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );
   
   float3 worldNormal = mul( Input.mNormal, (float3x3)gWorldMatrix );
   worldNormal = normalize(worldNormal);

   Output.mDiffuse = dot(-lightDir, worldNormal);
   Output.mReflection = reflect(lightDir, worldNormal);

   Output.mUV = Input.mUV;
   
   return Output;
}



새로 추가해야 할 전역변수가 있던가요? 새로 추가된 변수는 빛의 색상하고 2개의 텍스처 샘플러인데 텍스처 샘플러야 당연히 픽셀쉐이더에서 사용하는 거니까 여기선 선언하지 않아도 되겠네요. 빛의 색상도 픽셀쉐이더에서 그냥 곱하면 되겠는걸요?

그렇다면 정점쉐이더 입출력 구조체는 어떨까요? 새로 추가해야 할 게 하나 생겼죠? 픽셀쉐이더에서 텍스처매핑을 하려면 UV 좌표가 필요하니까요. 정점버퍼에서 UV 좌표를 가져와서 픽셀쉐이더에 전달해 줘야겠네요. 다음의 코드를 정점쉐이더의 입력구조체와 출력구조체 양쪽에 모두 추가합시다.

    float2 mUV: TEXCOORD0;


정점쉐이더 함수에 추가해야 할 코드도 딱 한 줄 뿐입니다. UV좌표를 전달해주는 것이죠.

   Output.mUV = Input.mUV;


정말 간단했죠? 이게 전부랍니다.



p.s. 여기는 댓글 달릴때마다 이메일이 오지 않아서 답변을 빨리빨리 드리기가 쉽지 않군요. 제 개인 블로그에 포스팅 된 버전에 댓글을 다시는게 제일 답변이 빠릅니다. 오탈자 및 잘못된 내용 수정도 제일 빠릅니다.


포프 올림



정 반사광

배경
정 반사광(specular light)[각주:1]은 난 반사광과는 달리 한 방향으로만 반사되는 빛으로 입사각이 출사각과 같은 것이 특징입니다. 따라서 정 반사광의 효과를 보려면 빛이 반사되는 방향에서 물체를 바라봐야만 합니다. 모니터에 빛이 반사 되서 화면을 보기 힘들었던 기억 있으시죠? 그 때 모니터를 조금 돌리면 조금 살만했던 거도요? 그게 바로 정 반사광입니다.

앞에서 보여 드렸던 난 반사광을 그림에 정 반사광도 추가해 보지요.

그림 4.8 난 반사광과 정 반사광




난 반사광과 마찬가지로 정 반사광을 수학적으로 재현해내는 수학공식이 여럿 있습니다. 여기서는 게임업계에서 널리 사용하는 기법인 퐁(phong) 모델을 사용하겠습니다. 퐁 모델은 반사광과 카메라벡터(카메라에서 현재 위치까지 선을 그은 벡터)가 이루는 각도의 코사인 값을 구하고, 그 결과를 여러번 거듭제곱하면 정 반사광을 구할 수 있다고 합니다. 아래의 그림을 보시죠.

그림 4.9. 정반사광의 예



반사광(R)과 카메라벡터(V)가 이루는 각도의 코사인 값을 구하는 것은 난 반사광에서 했던 것과 별반 차이가 없겠군요. 법선벡터와 입사광 벡터 대신에 반사광 벡터와 카메라벡터를 쓰는 것만 빼면요. 근데 왜 이 결과에 다시 거듭제곱을 할까요? 역시 코사인 그래프를 보면 답이 보입니다.

그림 4.10. 거듭제곱수가 늘어남에 따라 빠르게 줄어드는 코사인 그래프



위 그래프에서 보면 거듭제곱수가 늘어남에 따라 코사인 값이 빠르게 줄어드는 거 보이시죠? 실생활에서 정 반사광을 관찰해봅시다. 정반사광의 폭이 얼마나 되나요? 난 반사광에 비해 상당히 타이트하지 않나요? 바로 이런 타이트한 정 반사광을 재현하기 위해 코사인 값에 거듭제곱을 하는 겁니다.

그러면 거듭제곱은 몇 번이나 해야 할까요? 이건 사실 표면의 재질에 따라 다릅니다. 거친 표면일수록 정 반사광이 덜 타이트할 테니까 거듭제곱 수를 줄여줘야겠죠. 보통 한 20번 정도 거듭제곱을 해주면 대충 괜찮은 결과를 얻으실 수 있습니다.

그럼 이제 쉐이더를 작성해 봅시다.

기초설정
바로 조금 전에 작성했었던 난 반사광 쉐이더에 정 반사광 조명 코드를 추가하도록 하죠. 어차피 이 두 광이 합쳐져야 제대로 된 조명효과니까요.

그림 4.9에서 새로 추가된 것이 뭐가 있었죠? 반사광 벡터하고 카메라 벡터죠? 반사광 벡터야 입사광 벡터를 법선에 대해 반사시킨 것이니(입사각과 출사각이 같습니다) 이미 가지고 있는 정보에서 구할 수 있겠네요. 카메라 벡터는요? 입사광의 벡터를 구했던 것과 마찬가지 방법으로 카메라 위치에서 현재 위치까지 선을 쭈욱~ 그으면 되겠죠? 그러려면 카메라 위치를 전역변수로 만들어야 겠네요. 렌더몽키의 Lighting 쉐이더 위에 마우스 오른쪽 버턴을 눌러 새로운 float4 변수를 추가합시다. 이름은 gWorldCameraPosition이 적당하겠네요. 이제 이 변수 위에 마우스 오른쪽 버튼을 눌러 ViewPosition이라는 변수 시맨틱을 대입합니다.

이 외에 별다른 설정은 없는 것 같군요. 이제 정점쉐이더를 살펴봅시다.

정점쉐이더
마찬가지로 정점쉐이더의 전체 소스코드부터 보여드리겠습니다.

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;

float4 gWorldLightPosition;
float4 gWorldCameraPosition;

struct VS_INPUT 
{
   float4 mPosition : POSITION;
   float3 mNormal: NORMAL;
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;
   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;
};

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );

   float3 lightDir = Output.mPosition.xyz - gWorldLightPosition.xyz;
   lightDir = normalize(lightDir);
   
   float3 viewDir = normalize(Output.mPosition.xyz - gWorldCameraPosition.xyz);
   Output.mViewDir = viewDir;
   
   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );
   
   float3 worldNormal = mul( Input.mNormal, (float3x3)gWorldMatrix );
   worldNormal = normalize(worldNormal);

   Output.mDiffuse = dot(-lightDir, worldNormal);
   Output.mReflection = reflect(lightDir, worldNormal);

   return Output;
}


정점쉐이더 입력데이터 및 전역변수
일단 정점쉐이더 입력데이터를 보죠. 새로 필요한 정점정보가 있나요? 아무리 생각해도 별 다른 게 안 떠오르는 거 보니 없는 거 같네요. 난 반사광에 사용했던 입력구조체를 그냥 사용해도 될 거 같습니다.

그렇다면 전역변수는 어떻죠? 방금 전에 추가했던 gWorldCameraPosition을 선언해야겠죠? 다음의 코드를 추가합니다.

float4 gWorldCameraPosition;


정점쉐이더 출력데이터
이제 정점쉐이더 출력데이터를 살펴보도록 하죠. 난 반사광에서 그랬던 것처럼 정점쉐이더에서 정 반사광을 계산한 뒤에 픽셀쉐이더에 전달해 주면 될까요? 불행히도 그렇진 않습니다. 정 반사광을 구하려면 코사인 값에 거듭제곱을 해야 하는데 거듭제곱을 한 뒤 보간(interpolate)을 한 결과와 보간을 한 뒤에 거듭제곱을 한 결과의 차이는 엄청납니다. 따라서 정 반사광 계산은 픽셀 쉐이더에서 해야 하니 이 계산에 필요한 두 방향벡터인 R과  V를 구한 뒤에 픽셀쉐이더에 전달해 주도록 하겠습니다. 다음의 코드를 VS_OUTPUT에 추가합시다.

   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;


정점쉐이더 함수
이제 정 반사광을 계산하는데 필요한 두 방향벡터를 구해보죠. 카메라 벡터는 어떻게 구한다고 했었죠? 그냥 카메라 위치로부터 현재위치까지 선을 그으면 된다고 했죠? 입사광의 방향벡터를 구하는 것과 별 다를 바가 없겠네요. 입사광의 방향벡터를 구하는 코드 바로 아래에 다음의 코드를 추가합니다.

   float3 viewDir = normalize(Output.mPosition.xyz - gWorldCameraPosition.xyz);
   Output.mViewDir = viewDir;


이제 정 반사광의 방향벡터를 구할 차례입니다. 이 때, 빛의 입사각과 출사각이 같다고 말씀드렸었죠? 그럼 반사벡터를 구하는 수학 공식이 필요하겠군요. 근데 이런 공식은 굳이 기억하지 않으셔도 됩니다. (저도 수학책 다시 열어봐야 압니다. -_-) 여태까지 그랬던 것처럼 당연히 이런 것을 척척 처리해주는 HLSL 함수가 있겠죠? reflect()라는 함수입니다. reflect는 첫 번째 인자로 입사광의 방향벡터를 두 번째 인자로 반사 면의 법선을 받습니다. Output을 반환하기 바로 전에 다음의 코드를 입력합니다.

   Output.mReflection = reflect(lightDir, worldNormal);


자, 이제 두 벡터를 다 구해봤으니 정점쉐이더에서 할 일은 끝났습니다.

픽셀쉐이더
마찬가지로 픽셀쉐이더의 전체 코드부터 보여드립니다.

struct PS_INPUT
{
   float3 mDiffuse : TEXCOORD1;
   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;
};

float4 ps_main(PS_INPUT Input) : COLOR
{
   float3 diffuse = saturate(Input.mDiffuse);
   
   float3 reflection = normalize(Input.mReflection);
   float3 viewDir = normalize(Input.mViewDir); 
   float3 specular = 0;
   if ( diffuse.x > 0 )
   {
      specular = saturate(dot(reflection, -viewDir ));
      specular = pow(specular, 20.0f);
   }

   float3 ambient = float3(0.1f, 0.1f, 0.1f);
   
   return float4(ambient + diffuse + specular, 1);
}


우선 정점쉐이더 출력데이터에서 가져올 두 벡터를 PS_INPUT 구조체에 추가합니다.

   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;


이전에 diffuse를 구했던 코드 바로 밑에 새로운 코드들을 추가하겠습니다. 우선 mReflection과 mViewDir을 다시 한번 정규화시켜 줍니다. 정점쉐이더에서 이미 단위벡터로 만들었던 이 벡터들을 다시 정규화해 주는 이유는 보간기를 거치는 동안 그 값이 흐트러질 수 있기 때문입니다.[각주:2]

   float3 reflection = normalize(Input.mReflection);
   float3 viewDir = normalize(Input.mViewDir);


이제 이 두 벡터의 내적을 구한 뒤, 거듭제곱을 합니다.

   float3 specular = 0;
   if ( diffuse.x > 0 )
   {
      specular = saturate(dot(reflection, -viewDir ));
      specular = pow(specular, 20.0f);
   }


위에서 난반사광의 양이 0% 이상일 때에만 정 반사광을 계산하는 거 보이시죠? 난 반사광이 존재하지 않는 표면에는 이미 빛이 닿지 않으므로 정 반사광이 존재할 수가 없기 때문입니다. 내적을 구할 때 -viewDir을 사용한 것도 보이시죠? 난 반사광을 구할 때와 마찬가지로 두 벡터의 밑동이 만나야 올바른 내적의 결과를 구할 수 있기 때문입니다.

또한 거듭제곱을 할 때 pow() 함수를 이용한 것도 눈 여겨 봐주시기 바랍니다. 여기서는 20번 거듭제곱을 했는데 각 물체마다 이 값을 다르게 하는 것이 보통입니다.[각주:3] 따라서 이 값을 float형의 전역변수로 선언해주는 게 보다 나은 방법이 되겠습니다. 이 정도는 독자 분들의 몫으로 남겨두도록 하지요.

이제 결과를 반환할 차례입니다. 일단 정 반사광의 효과만을 보기 위해 specular만을 반환해볼까요? 이전에 있던 return문을 다음과 같이 바꿉니다.

  return float4(specular, 1);


이제 쉐이더를 컴파일한 뒤 실행해보면 다음의 결과를 보실 수 있을 것입니다.

그림 4.11. 난 반사광에 비해 매우 강렬하고 타이트한 하이라이트를 보여주는 정 반사광



이제 정 반사광이 어떤 건지 확실히 보이시죠? 여기에 난 반사광을 더하면 보다 완벽한 조명효과가 되겠네요. return 코드를 다음과 같이 바꿔봅시다.

    return float4(diffuse + specular, 1);


위 코드에서 난 반사광과 정 반사광을 더하면 그 결과가 1이 넘는 경우가 있는데 크게 걱정하지 않으셔도 됩니다. 그런 경우엔 알아서 1이 됩니다.[각주:4]

이제 정점쉐이더와 픽셀쉐이더를 각각 컴파일 하신 뒤 미리 보기 창을 보면 다음과 같은 결과가 보이죠?

그림 4.12. 난 반사광 + 정 반사광




자, 이 정도면 훌륭한 조명효과입니다. 하지만 공의 왼쪽 밑부분이 칠흑같이 어두운 게 좀 망에 안 드는군요. 앞서 말씀 드렸다시피 실제세계에서는 간접광이 저 어두운 부분을 비춰줄 텐데 말이지요. 그럼 아주 간단하게 주변광을 정의해줘서 저 부분을 조금이나마 밝혀볼까요? 주변광을 10%로 선언해서 ambient 변수에 대입해주도록 합시다.

    float3 ambient = float3(0.1f, 0.1f, 0.1f);


그리고 최종 반환 값에 ambient를 추가합니다.

   return float4(ambient + diffuse + specular, 1);



이제 결과가 아래와 같이 바뀔 겁니다.

그림 4.13. 주변광 + 난 반사광 + 정 반사광




선택사항: DirectX 프레임워크
이제 C++로 작성한 DirectX 프레임워크에서 쉐이더를 사용하시고자 하는 분들을 위한 선택적인 절입니다.

우선 '제3장: 텍스처매핑'에서 사용했던 프레임워크의 사본을 만들어 새로운 폴더에 저장합니다. 그 다음, 렌더몽키에서 사용했던 쉐이더와 3D 모델을 DirectX 프레임워크에서 사용할 수 있도록 파일로 저장합니다. Sphere.x와 Lighting.fx라는 파일이름을 사용하도록 하겠습니다.

이제 비주얼 C++에서 솔루션 파일을 엽니다.

자, 그럼 전역변수를 먼저 살펴보겠습니다. 일단 이 장에서는 텍스처를 사용하지 않으니 저번 장에서 선언했던 텍스처 변수, gpEarthDM를 지우겠습니다. 그 다음, 쉐이더 변수의 이름을 gpTextureMappingShader에서 gpLightingShader로 바꿉니다.

이제 새로운 변수들을 선언할 차례입니다. 광원의 위치와 카메라의 위치가 필요했었죠? 이 둘은 모두 월드공간 안에 있었네요. 렌더몽키에서 사용했던 빛의 위치를 다시 사용하겠습니다.

// 빛의 위치
D3DXVECTOR4                gWorldLightPosition(500.0f, 500.0f, -500.0f, 1.0f);


카메라 위치는 예전에 RenderScene() 함수 안에서 사용했던 값을 그대로 가져왔습니다.

// 카메라 위치
D3DXVECTOR4                gWorldCameraPosition( 0.0f, 0.0f, -200.0f, 1.0f );


이제 CleanUp() 함수로 가봅시다. 더 이상 gpEarthDM 텍스처를 사용하지 않으니 이를 해제하는 코드를 지웁니다.

다음은 LoadAssets() 함수 입니다. 우선 gpEarthDM 텍스처를 로딩하는 코드를 삭제합니다. 그리고 쉐이더의 파일명을 Lighting.fx로 바꿉니다. gpTextureMappingShader라는 변수명을gpLightingShader로 바꾸는 것도 잊지 마세요.

    // 텍스처 로딩

    // 쉐이더 로딩
    gpLightingShader = LoadShader("Lighting.fx");
    if ( !gpLightingShader )
    {
        return false;
    }


마지막으로 RenderScene() 함수를 보겠습니다. 일단gpTextureMappingShader 라는 변수명을 모두 찾아gpLightingShader로 바꿉니다. 이제 뷰행렬을 만드는 코드를 보죠. 뷰행렬을 만들 때 사용했던 vEyePt라는 변수가 있었죠? 이 변수의 값이 앞서 정의했던 gWorldCameraPosition의 값과 동일하니 gWolrldCameraPosition의 값을 사용하도록 하지요.

예전에 아래처럼 되어 있던 코드를
 

    D3DXVECTOR3 vEyePt( 0.0f, 0.0f, -200.0f );


다음과 같이 바꿉니다.

    D3DXVECTOR3 vEyePt( gWorldCameraPosition.x, gWorldCameraPosition.y, 
        gWorldCameraPosition.z ); 


이제gpLightingShader->SetTexture() 코드를 지웁니다. 이 장에서 만든 쉐이더에는 텍스처를 사용하지 않으니 이 코드가 필요 없습니다. 그럼 마지막으로 광원의 위치와 카메라의 위치를 쉐이더에 전달해 줍니다. 이들의 데이터형은 D3DXVECTOR4이므로 쉐이더에서 SetVector()를 호출합니다.

    gpLightingShader->SetVector("gWorldLightPosition", &gWorldLightPosition);
    gpLightingShader->SetVector("gWorldCameraPosition", &gWorldCameraPosition);


이제 코드를 컴파일 한 뒤 실행해보시죠. 아까 렌더몽키에서 보셨던 것과 동일한 결과를 볼 수 있죠?

기타 조명기법
여전히 대부분의 게임이 사용하는 조명기법은 람베르트 + 퐁이지만 최근 들어 다른 조명기법들을 사용하는 게임들이 늘어나고 있습니다. 조명기법을 좀 더 심층적으로 연구하고 싶으신 독자 분들을 위해 몇 가지 기법을 언급하겠습니다.

  • 블린-퐁(Blinn-Phong): 퐁과 거의 비슷한 기법. 현재도 많이 사용함
  • 오렌-네이어(Oren-Nayar): 표면의 거친 정도를 고려한 난 반사광 조명기법
  • 쿡-토런스(Cook-Torrance): 표면의 거친 정도를 고려한 정 반사광 조명기법
  • 구면조화 조명기법(spherical harmonics lighting): 오프라인에서 간접광을 사전 처리한 뒤, 실시간에서 이를 주변광으로 적용할 때 사용할 수 있음

정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.
  • 람베르트 모델은 난 반사광을 계산하는 기법으로 코사인 함수를 사용한다.
  • 퐁 모델은 정 반사광을 계산하는 기법으로 코사인 값을 거듭제곱 한다.
  • 벡터의 길이를 1로 바꾸면 내적을 구하는 것만으로도 코사인 함수를 대신할 수 있다.
  • 동일한 계산을 어느 쪽에서도 할 수 있다면 픽셀쉐이더 보다는 정점쉐이더에서 한다.
  • 이 장에서 배운 조명보다 훨씬 사실적이고 복잡한 기법들이 존재한다. 그 중 일부는 이미 몇몇 게임에서 쓰이고 있다.


이제 조명기법까지 마쳤으니 쉐이더의 기초는 다 배운 거나 다름없습니다. 다음 장부터는 여태까지 배웠던 지식들을 잘 혼합하여 보다 실용적인 기법들을 구현해 보겠습니다. 제1~4장 중에 잘 이해가 안 되는 내용이 있었다면 다시 한 번 복습을 하신 뒤에 제5장으로 넘어오시기 바랍니다.


p.s. 여기는 댓글 달릴때마다 이메일이 오지 않아서 답변을 빨리빨리 드리기가 쉽지 않군요. 제 개인 블로그에 포스팅 된 버전에 댓글을 다시는게 제일 답변이 빠릅니다. 오탈자 및 잘못된 내용 수정도 제일 빠릅니다.


포프 올림
  1. 이것을 반사광이라 부르기도 합니다. [본문으로]
  2. 보간기가 선형적(linear)으로 보간을 해서 그렇습니다. [본문으로]
  3. 거듭제곱의 수가 높을 수록 정반사광의 범위가 타이트해집니다. 숫자를 바꿔보면서 실험해보세요. [본문으로]
  4. 현재 하드웨어 백버퍼의 포맷이 8비트 이미지이기 때문입니다. 부동소수점 텍스처를 사용하면 1 이상의 값을 저장할 수도 있습니다. [본문으로]

[Bullet Physics] RigidBody 만들기

물리 2012. 1. 5. 00:01 Posted by 알 수 없는 사용자

안녕하세요 물리프로그래머 조성현 입니다.
모든 분들 새해 복 많이 받으시고요, 오늘은 가장 간단한 RigidBody(박스, 구, 캡슐, 실린더)를 만들어 보겠습니다.


1) Box생성
크기가 (1, 1, 1)의 박스를 위치(-4, 0, 10)에 생성하는 코드 입니다.
내용은 아래와 같습니다.
(1) 위치를 (-4, 0 , 10)로 정한다
(2) (1, 1, 1)크기의 btBoxShape를 생성한다.
(3) 박스 모양와 질량으로 localInertia를 구한다.(Inertia에 대해선 추후 설명)
(4) 위의 요소들을 이용해서 RigidBody를 만든다.
(5) World에 등록한다.

  // 질량을 1로 설정
  const float mass = 1.0f;

  // (1) RidigBody를 (-4, 0, 10)에 생성
  btTransform bodyTM;
  bodyTM.setIdentity();
  bodyTM.setOrigin(btVector3(-4.0f, 0.0f, 10.0f));

  // (2)Box 생성
  btCollisionShape* boxShape = new btBoxShape(btVector3(1.0f, 1.0f, 1.0f) );

  // (3)IneritaTensor를 계산
  btVector3 localInertia(0,0,0);
  boxShape->calculateLocalInertia(mass, localInertia);

  // (4)RigidBody 생성
  btDefaultMotionState* myMotionState = new btDefaultMotionState(bodyTM);
  btRigidBody::btRigidBodyConstructionInfo rbInfo(mass, myMotionState, boxShape, localInertia);
  btRigidBody* body = new btRigidBody(rbInfo);

  // (5)World에 등록
  m_dynamicsWorld->addRigidBody(body);



2) Sphere생성
Radius가 1인 Sphere를 위치(-2, 0, 10)에 생성하는 코드 입니다.
내용은 Box생성과 동일합니다.

  //질량을 1로 설정
  const float mass = 1.0f;

  //(1) RidigBody를 (-2, 0, 10)에 생성
  btTransform bodyTM;
  bodyTM.setIdentity();
  bodyTM.setOrigin(btVector3(-2.0f, 0.0f, 10.0f));

  // (2) Sphere 생성
  btCollisionShape* sphereShape = new btSphereShape(1.0f);

  // (3) IneritaTensor를 계산
  btVector3 localInertia(0,0,0);
  sphereShape->calculateLocalInertia(mass, localInertia);

  // (4)RigidBody 생성
  btDefaultMotionState* myMotionState = new btDefaultMotionState(bodyTM);
  btRigidBody::btRigidBodyConstructionInfo rbInfo(mass, myMotionState, sphereShape, localInertia);
  btRigidBody* body = new btRigidBody(rbInfo);

  (5)  World에 등록
  m_dynamicsWorld->addRigidBody(body);



3) Capsule생성
Radius가 0.5, Height가 2인 Capsule을 위치(2, 0, 10)에 생성하는 코드 입니다.

  //질량을 1로 설정
  const float mass = 1.0f;

  //(1) RidigBody를 (2, 0, 10)에 생성
  btTransform bodyTM;
  bodyTM.setIdentity();
  bodyTM.setOrigin(btVector3(2.0f, 0.0f, 10.0f));

  //(2) Capsule 생성
  btCollisionShape* capsuleShape = new btCapsuleShapeZ(0.5f, 2.0f);

  //(3) IneritaTensor를 계산
  btVector3 localInertia(0,0,0);
  capsuleShape->calculateLocalInertia(mass, localInertia);

  //(4) RigidBody를 생성
  btDefaultMotionState* myMotionState = new btDefaultMotionState(bodyTM);
  btRigidBody::btRigidBodyConstructionInfo rbInfo(mass, myMotionState, capsuleShape, localInertia);
  btRigidBody* body = new btRigidBody(rbInfo);

  //(5) World에 등록
  m_dynamicsWorld->addRigidBody(body);

4) Cylinder생성
Radius가 0.5, Height(Half)가 1인 Cylinder를 위치(4, 0, 10)에 생성하는 코드 입니다.

  //질량을 1로 설정

  const float mass = 1.0f;

  //(1) RidigBody를 (0, 0, 10)에 생성
  btTransform bodyTM;
  bodyTM.setIdentity();
  bodyTM.setOrigin(btVector3(4.0f, 0.0f, 10.0f));

  //(2) Cylinder 생성                                                                
  btCollisionShape* cylinderShape = new btCylinderShapeZ(btVector3(0.5f, 0.5f, 1.0f));

  //(3) IneritaTensor를 계산
  btVector3 localInertia(0,0,0);
  cylinderShape->calculateLocalInertia(mass, localInertia);

  //(4) RigidBody를 생성
  btDefaultMotionState* myMotionState = new btDefaultMotionState(bodyTM);
  btRigidBody::btRigidBodyConstructionInfo rbInfo(mass, myMotionState, cylinderShape, localInertia);
  btRigidBody* body = new btRigidBody(rbInfo);

  //(5) World에 등록
  m_dynamicsWorld->addRigidBody(body);


 

5) 결과
(1) "Create" 버튼을 누르면 RigidBody가 생성됨(계속)
(2) "Release"버튼을 누르면 모두 삭제됨.
(3) "Alt"를 누른상태에서, 마우스 L, R, Wheel로 카메라를 조절함.


다음에는 Friction, Restitution, Damping과 같은 RigidBody의 속성에 대해 설명하겠습니다. 

  

'물리' 카테고리의 다른 글

Inertia Tensor(2)  (0) 2012.02.19
Inertia Tensor(1)  (0) 2012.02.13
RigidBody의 Restitution, Friction, Damping  (2) 2012.02.06
[Bullet Physics] Bullet 물리엔진의 설치  (2) 2011.12.22
기초설정
  1. 여태까지 해왔던 것과 마찬가지로 렌더몽키 안에서 새로운 DirectX 이펙트를 만든 뒤, 정점쉐이더와 픽셀쉐이더의 코드를 삭제합니다.
  2. 이제 쉐이더의 이름을 Lighting으로 바꾸도록 합시다.
  3. 정점의 위치를 변환할 때, 필요한 gWorldMatrix, gViewMatrix, gProjectionMatrix를 추가하고 변수 시맨틱에 연결해주는 것도 잊지 마세요.


람베르트 모델을 이용해서 난 반사광을 계산하려면 어떤 정보가 필요했었죠? 입사광의 벡터와 표면법선 벡터입니다. 법선 정보는 각 정점에 저장되어 있는 것이 보통입니다.[각주:1] 따라서 정점버퍼로부터 이 정보를 가져와야 합니다. 저번 장에서 정점버퍼에서 UV좌표를 불러오기 위해 별도로 해줬던 설정이 있었죠? 렌더몽키의 작업공간 패널에서 Stream Mapping을 더블클릭 한 뒤, NORMAL이란 새로운 필드를 추가합니다. 법선은 3차원 공간에 존재하는 벡터이니 FLOAT3로 선언해주겠습니다. Attribute Name은 크게 신경 쓰지 않으셔도 되지만 Index를 0으로 해주는 것은 잊지 마세요.

그렇다면 입사광의 벡터는 어떻게 구할까요? 사실 이거 별거 아닙니다. 그냥 광원의 위치에서 현재 픽셀 위치까지 직선을 하나 그으면 그게 입사광의 벡터입니다. 따라서 광원의 위치만 알면 입사광의 벡터는 쉽게 구할 수 있습니다. 그렇다면 광원의 위치는 어떻게 정의할까요? 그냥 '월드에서 (500, 500, -500)에 있는 광원' 정도로 하면 되겠지요? 따라서 광원은 전역변수가 됩니다. 렌더몽키의 작업공간에서 Lighting 위에 마우스 오른쪽 버튼을 누른 뒤, Add Variable > Float > Float4를 선택합니다. 새로 생긴 변수의 이름을 gWorldLightPosition이라 바꾼 뒤, 변수 이름을 더블클릭하여 광원의 위치를 (500, 500, -500, 1)으로 설정합니다.

이 모든 설정을 마쳤으면 작업공간이 다음 그림과 같을 것입니다.

그림 4.6. 기초설정을 마친 렌더몽키 프로젝트



정점쉐이더
일단 전체 소스코드부터 보여드린 뒤, 한 줄씩 차근차근 설명해드리겠습니다.

struct VS_INPUT
{
    float4 mPosition : POSITION;
    float3 mNormal : NORMAL;
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;
};

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;

float4 gWorldLightPosition;

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );


   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );
   float3 lightDir = Output.mPosition.xyz - gWorldLightPosition.xyz;

   lightDir = normalize(lightDir);

   float3 worldNormal = mul( Input.mNormal, (float3x3)gWorldMatrix );
   worldNormal = normalize( worldNormal );

   Output.mDiffuse = dot(-lightDir, worldNormal);

   return Output;
}



정점쉐이더 입력데이터
우선 '제2장: 진짜 쉬운 빨강쉐이더'에서 사용했던 입력데이터의 구조체를 가져와 보도록 하지요.

struct VS_INPUT
{
    float4 mPosition : POSITION;
};



자, 이제 여기에 법선을 더해야겠죠? 정점버퍼에서 법선을 가리키는 시맨틱은 NORMAL입니다. 법선은 3차원 공간에서 방향을 나타내는 벡터이니 float3가 되겠군요.

struct VS_INPUT
{
    float4 mPosition : POSITION;
    float3 mNormal : NORMAL;
};


정점쉐이더 함수
이번 장에서는 정점쉐이더 함수를 먼저 작성한 뒤, 정점쉐이더 출력데이터와 전역변수를 살펴보겠습니다. 아무래도 함수부터 살펴보는 것이 이해가 더 잘 될 겁니다.
 
우선 정점위치를 변환하는 코드부터 보겠습니다.

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );


   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );


위 코드는 더 이상 설명하지 않아도 잘 아시리라 믿습니다. 그렇다면 이 이외에 정점쉐이더에서 따로 계산할 것들이 뭐가 있을까요? 난 반사광을 계산하려면 입사광의 벡터와 법선의 내적을 구해야 하는데 과연 이런 일을 정점쉐이더 안에서 해야 할까요? 아니면 픽셀쉐이더 안에서 해야 할까요? 잠시 짬을 내어서 생각해 보세요.

...

자, 뭐가 정답이라고 생각하세요? 사실 정답은 없습니다. 어느 쪽에서도 이 계산을 할 수 있거든요. 정점쉐이더에서 이 계산을 한다면 정점마다 두 벡터의 내적을 구한 뒤, 그 결과를 VS_OUPUT의 일부로 반환할 것입니다. 그러면 보간기를 통해 보간된 값들이 픽셀쉐이더에 전달될 것이니 픽셀쉐이더는 그냥 그 값을 가져다가 사용하면 됩니다.

이와 반대로 픽셀쉐이더에서 이 계산을 한다면 정점쉐이더가 법선정보를 VS_OUTPUT의 일부로 반환할 것이고, 픽셀쉐이더는 보간된 법선을 읽어와 입사광의 벡터와 내적을 구하겠지요.

어느 쪽에서 계산을 해도 차이가 없다면[각주:2] 당연히 성능상 유리한 쪽을 택해야겠죠? 어느 쪽에서 계산을 해야 더 빠른지는 각 함수가 호출되는 횟수를 따져보면 알 수 있습니다.[각주:3] 삼각형을 하나 그려보시죠. 이 삼각형을 그릴 때, 정점쉐이더가 몇 번 실행될까요? 삼각형을 이루는 정점의 수가 셋이니 3번 실행됩니다. 그렇다면 픽셀쉐이더는 몇 번 실행될까요? 삼각형이 화면에서 차지하는 픽셀 수만큼입니다. 물론 삼각형이 매우 작아서 화면에서 픽셀 하나 크기 밖에 안 된다면 픽셀쉐이더는 한번만 실행됩니다. 하지만, 보통 삼각형이 차지하는 픽셀 수가 3개는 넘겠지요? 따라서 동일한 계산이라면 픽셀쉐이더 보단 정점쉐이더에서 하는 것이 낫습니다. 그럼, 난 반사광의 계산도 정점쉐이더에서 하겠습니다.

 - 동일한 계산을 어느 쪽에서도 할 수 있다면 픽셀쉐이더 보단 정점쉐이더에서 하는 것이 성능 상 유리합니다.


그럼 먼저 입사광 벡터를 만들어 보도록 하지요. 입사광의 벡터는 광원의 위치에서 현재 위치까지 선을 쭈욱~ 그으면 된다고 했죠? 이렇게 선을 쭉 긋는 것을 벡터의 뺄셈이라 합니다. 즉, 현재 위치에서 광원의 위치를 빼면 입사광의 벡터를 구할 수 있습니다. 하지만, 한가지 주의해야 할 점이 있습니다. 3D 수학에서 올바른 결과를 얻으려면 모든 변수의 공간이 일치해야 합니다. 앞서 광원의 위치를 이미 월드공간에서 정의했었죠? 그렇다면 정점의 위치는 어느 공간에 있을까요? Input.mPosition은 지역공간에, Output.mPosition은 투영공간에 있네요. 저희가 필요한 것은 월드공간인데 말이지요. 아까 보여드렸던 정점쉐이더의 코드를 다시 한 번 살펴볼까요? 월드행렬을 곱한 다음에 빈 칸을 좀 남겨둔 것이 보이시죠? 월드행렬을 곱한 직후의 Output.mPosition이 바로 월드공간에서의 위치이니 이것에서 광원의 위치를 빼면 되겠네요. 그럼 월드행렬을 곱하는 코드 바로 밑에 다음의 코드를 추가하죠.

   float3 lightDir = Output.mPosition.xyz - gWorldLightPosition.xyz;


이제 이 벡터의 길이를 1로 만듭시다. 벡터의 길이가 1이면 내적만으로도 코사인 값을 구할 수 있다고 했죠? 이렇게 벡터의 길이를 1로 만드는 과정을 정규화(normalize)라고 한다는 것도 말씀드렸던가요? 수학적으로 단위 벡터를 만들려면 각 성분을 벡터의 길이로 나누면 됩니다. 하지만 그 대신 HLSL에서 제공하는 정규화 함수, normalize()를 사용하도록 하지요.

   lightDir = normalize(lightDir);


이제 입사광의 벡터가 준비되었으니 법선을 가져올 차례지요? 정점쉐이더 입력데이터에 있는 법선을 그냥 사용하면 될까요? 이 법선은 어느 공간 안에 있죠? 정점버퍼에서 곧바로 오는 데이터니까 당연히 물체공간이겠죠? 그렇다면 이 법선을 월드공간으로 변환해 줘야만 제대로 난 반사광을 구할 수 있겠네요. 

주의 - 3D 연산을 할 때는 모든 변수들이 존재하는 공간이 일치해야 합니다.


   float3 worldNormal = mul( Input.mNormal, (float3x3)gWorldMatrix );


이 위의 코드에서 Input.mNormal이 float3형이니 월드행렬을 그에 맞게 3 X 3 행렬로 바꾼 거 보이시나요? (float3x3)를 앞에 붙이는 방법으로 캐스팅을 했네요. 4 X 4행렬에서 4번째 행(또는 열)은 평행이동(translation) 값이므로 방향벡터에 아무런 영향도 미치지 않습니다.[각주:4]

이 벡터를 단위벡터로 만드는 것도 잊지 마세요.

   worldNormal = normalize( worldNormal );


이제 입사광의 벡터와 법선이 모두 준비되었으니 내적을 구할 차례입니다. 내적의 공식이 어떻게 되었었죠? 사실 별로 어려운 공식은 아니었는데 굳이 기억하자니 귀찮군요. 그 대신 HLSL자체에서 제공하는 내적함수, dot()을 사용하겠습니다.

   Output.mDiffuse = dot(-lightDir, worldNormal);


위 코드를 보니 내적을 구한 결과를 mDiffuse라는 출력변수에 대입해줬군요. 근데 위에서 lightDir 대신 -lightDir을 쓴 거 보이시죠? 이렇게 한 이유는 두 벡터의 내적을 구할 때, 화살표의 밑동이 서로 만나야 하기 때문입니다. lightDir을 쓰면 입사광 벡터의 머리가 법선의 밑동과 만나므로 잘못된 결과를 발생시킵니다.

또한 내적의 결과는 실수 하나인데 float3인 mDiffuse에 곧바로 대입해준 거 보이시죠? 이렇게 하면 float3의 세 성분이 모두 이 실수 값으로 채워집니다. dot(-lightDir, worldNormal).xxx을 대입해주는 것과 동일하지요.
 
이제 간단히 결과를 반환해 줍시다.

   return Output;
}


전역변수
이제 왜 쉐이더함수를 먼저 살펴봤는지 아시겠나요? 아무 설명 없이 '빛의 위치를 전역변수로 선언하겠습니다.'라고 말씀드리기가 뭐해서 였습니다.

다음의 전역변수들을 소스 코드 제일 위에 추가해주세요.

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;

float4 gWorldLightPosition;


정점쉐이더 출력데이터
정점쉐이더 함수를 짜보면서 이미 살펴봤듯이 출력데이터는 mPosition과 mDiffuse입니다. 위치야 float4형에 POSITION 시맨틱을 쓰는 걸 이미 알고 있는데, mDiffuse에는 어떤 형과 시맨틱을 써야 할까요? 두 벡터의 내적을 구하면 그 결과는 벡터가 아닌 숫자 하나입니다.[각주:5] 따라서 float만 사용해도 사실 큰 문제는 아니지만 나중에 이 값을 픽셀의 RGB값으로 출력할 것이니 그냥 float3를 사용하겠습니다. 그렇다면 시맨틱은 어떻게 할까요? DIFFUSELIGHTING이라는 시맨틱이 존재할까요? 불행히도 그렇지 않습니다.[각주:6] 쉐이더 프로그래밍을 하다 보면 용도에 딱 맞는 시맨틱이 없는 경우가 종종 있는데, 그럴 때는 그냥 TEXCOORD 시맨틱을 사용하는 게 보통입니다. 최소한 8개[각주:7]의 TEXCOORD가 존재하니까 별로 모자라는 경우가 없거든요. 여기서는 TEXCOORD1을 사용하겠습니다.[각주:8]

다음의 출력데이터를 소스 코드 제일 위에 추가해주세요.

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;
};


픽셀쉐이더
막상 픽셀쉐이더를 짜려고 하니까 뭔가 허무한데요? 정점쉐이더가 난 반사광까지도 계산해 줬으니 픽셀쉐이더가 할 일이라곤 그냥 그 값을 가져다가 출력하는 정도겠네요. 그런데 내적은 결국 코사인 함수니까 -1~1의 결과 값을 가지겠죠? 난반사광의 범위는 0~1이니까 -1이하인 값을 0으로 바꾸도록 하죠. 물론 if문을 사용할 수도 있지만 그보다 훨씬 빠른 HLSL함수를 사용하도록 하죠. saturate()라는 함수는 0 이하의 값을 0으로, 1 이상의 값을 1로 바꿔줍니다. 그리고 이 함수는 성능에 아무 영향을 미치지 않는 공짜 함수입니다.

struct PS_INPUT
{
   float3 mDiffuse : TEXCOORD1;
};

float4 ps_main(PS_INPUT Input) : COLOR
{
   float3 diffuse = saturate(Input.mDiffuse);
   return float4(diffuse, 1); 
}


위 코드에서 float4(diffuse, 1)이란 형식으로 float4 변수를 만든 것 보이시나요? float4 변수를 만드는 생성자 정도로 생각하시면 되겠네요.

이제 F5를 눌러 정점쉐이더와 픽셀쉐이더를 각각 컴파일 한 뒤, 미리 보기 창을 보시면 부드러운 난 반사광 효과를 볼 수 있으실 겁니다.


그림 4.7 난 반사광 효과




p.s. 여기는 댓글 달릴때마다 이메일이 오지 않아서 답변을 빨리빨리 드리기가 쉽지 않군요. 제 개인 블로그에 포스팅 된 버전에 댓글을 다시는게 제일 답변이 빠릅니다. 오탈자 및 잘못된 내용 수정도 제일 빠릅니다.


포프 올림
 

  1. 언제나 그렇지는 않습니다. 나중에 법선매핑(normal mapping)을 배울 때 법선을 구하는 다른 방법을 알아봅니다. [본문으로]
  2. 사실 정점쉐이더에서 구한 결과와 픽셀쉐이더에서 구한 결과 사이에는 약간의 차이가 있습니다. 하지만 거의 눈에 띄지 않을 정도입니다. [본문으로]
  3. 이 외에도 성능을 저해하는 여러 가지 요인이 있으니 그냥 가이드라인으로만 생각하세요. [본문으로]
  4. 동일한 방향을 가리키는 화살표 2개를 다른 위치에 놓는다고 해서 그 방위가 바뀌지 않지요? 따라서 방향벡터에서 평행이동 값은 아무 의미도 없습니다. [본문으로]
  5. 이것을 스케일러(scalar)라고 합니다. 보통은 스칼라라고 많이 하시는데 스케일러가 맞는 발음입니다. [본문으로]
  6. 여기서 COLOR0 시맨틱을 사용하지 않은 이유는 정점쉐이더 2.0 규격에서 COLOR 시맨틱을 사용한면 변수의 값이 0~1 사이로 클램프 되기 때문입니다. 따라서 보간기를 거쳐 펙셀쉐이더에서 이 값을 넘겨받으면 좀 오차가 보이더군요. [본문으로]
  7. TEXCOORD0 ~ TEXCOORD7 [본문으로]
  8. TEXCOORD0 대신 TEXCOORD1을 사용한 이유는 다음 장에서 TEXCOORD0을 텍스처의 UV 좌표로 쓰기 위해서입니다. [본문으로]
샘플파일
 

제4장 기초적인 조명쉐이더

이 장에서 새로 배우는 HLSL
  • NORMAL: 정점의 법선정보를 불러올 때 사용하는 시맨틱
  • normalize(): 벡터 정규화 함수
  • dot(): 내적 함수
  • saturate(): 0 ~ 1을 넘어서는 값의 범위를 짤라 냄.
  • reflect(): 벡터반사 함수
  • pow(): 거듭제곱 함수



이 장에서 새로 사용하는 수학
  • 내적: 코사인 함수를 재빨리 계산할 때 사용할 수 있음
  • 정규화: 벡터를 단위벡터(길이가 1인 벡터)로 만듬.




빛이 존재하지 않는다면 물체를 볼 수 없습니다. 매우 당연한 이치인데도 이걸 까먹고 지내는 분들이 많은 것 같습니다. (저도 종종 까먹습니다.) 예를 들어, 창문이 하나도 없는 방에 들어가서 문을 닫아버리면 아무것도 볼 수가 없지요? 어디서 새어 들어오는 빛이 있지 않는 한 아무리 어둠 속에서 오래 있어도 아무것도 보이지 않습니다. 이 당연한 사실을 자꾸 까먹는 이유는 실생활에서 완전히 칠흑 같은 어둠을 찾기가 쉽지 않기 때문입니다. 왜일까요? 바로 끝없이 반사하는 빛의 성질 때문입니다. 딱히 눈에 뜨이는 광원이 없더라도 대기중의 미세입자에 반사되어 들어오는 빛까지 있으니까요. 이렇게 다른 물체에 반사돼서 들어오는 빛을 간접광이라 합니다. 반대로 직접광은 광원으로부터 직접 받는 빛입니다. 그림 4.1에 예를 들어보겠습니다.

그림 4.1 직접광과 간접광의 예




직접광과 간접광 중에서 어떤 것을 더 쉽게 계산할 수 있을까요? 위 그림만 봐도 딱 답이 나오죠? 직접광입니다. 간접광은 수없이 반사의 반사를 거치므로 당연히 직접광보다 계산하기 어렵습니다. 간접광을 계산하는 방법 중 하나로 광선추적(ray-tracing)이라는 기법이 있습니다. 아마 3D 그래픽에 관심이 많으신 분들이라면 최근 들어 광선추적에 대해 논하는 많은 자료를 보셨을 겁니다. 하지만, 아직도 실시간 광선추적기법이 게임에서 널리 사용되지 않는 이유는 하드웨어 사양이 따라주지 않기 때문이라죠.[각주:1] 그렇기 때문에 아직도 컴퓨터 게임 등을 비롯한 실시간 3D 프로그램에서는 주로 직접광만을 제대로 계산하고 간접광은 흉내내기 정도로 그치는 게 보통입니다. 따라서 이 장에서도 직접광만을 다루도록 하겠습니다.[각주:2] 참고로 이 장에서 배우는 조명 쉐이더는 아직까지도 대부분의 게임에서 표준으로 사용하는 기법이므로 잘 숙지해 두세요.

빛을 구성하는 요소는 크게 난 반사광(diffuse light)과 정 반사광(specular light)이 있습니다. 이 둘을 따로 살펴보도록 하겠습니다.

난 반사광
배경
대부분의 물체는 스스로 빛을 발하지 않습니다. 그럼에도 저희가 이 물체들을 지각할 수 있는 이유는 다른 물체(예, 태양)가 발산하는 빛이 이 물체의 표면에서 반사되기 때문입니다. 이 때, 여러 방향으로 고르게 반사되는 빛이 있는데 이것을 난 반사광(diffuse light)[각주:3]이라고 합니다. 어느 방향에서 바라봐도 물체의 명암이나 색조가 크게 변하지 않는 이유를 아시나요? 여러 방향으로 고르게 퍼지는 난 반사광 덕분입니다. 만약 빛이 한 방향으로만 반사된다면[각주:4] 그 방향에서만 물체를 지각할 수 있겠지요.

참고로 물체의 표면이 거칠수록 난반사가 심해지는 것이 보통입니다.[각주:5]

일단 난 반사광을 그림으로 그려 보겠습니다.

그림 4.2. 난 반사광



그림 4.2에서 아직 보여 드리지 않은 것이 조금 후에 배워 볼 정 반사광입니다. 정 반사광이 무엇인지는 나중에 알려 드릴 테니 일단은 입사광 중의 일부는 난 반사광이 되고 다른 일부는 정 반사광이 된다고만 기억해 두세요.

자, 그렇다면 수학적으로 난 반사광을 어떻게 계산할까요? 당연히 수학자마다 다른 주장을 하지만 그 중에서 게임에서 주로 사용하는 람베르트(lambert) 모델을 살펴봅시다. 요한 람베르트라는 수학자가 창시한 람베르트 모델은 표면법선[각주:6]과 입사광이 이루는 각의 코사인 값을 구하면 그게 바로 난 반사광의 양이라고 합니다. 그렇다면 일단 코사인 함수의 그래프를 볼까요?

그림 4.3. y = cos(x) 그래프




위 그래프를 보시면 입사광과 표면 법선의 각도가 0일 때, 결과(y축의 값)가 1인 거 보이시죠? 그리고 각도가 늘어날수록 결과가 점점 작아지다가 90도가 되니 0이 돼버립니다. 여기서 더 나아가면 그 후로는 아예 음수 값이 돼버리네요? 그러면 실제 세계에서 빛의 각도에 따라 결과가 어떻게 바뀌는지 살펴 볼까요?

그림 4.4. 입사광과 법선이 이루는 다양한 각도



위의 그림에서 평면이 가장 밝게 빛나는 때가 언제일까요? 당연히 해가 중천에 떠있을 때겠죠? (그림 a) 그리고 해가 저물어감에 따라 점점 표면도 어두워지겠네요. (그림 b) 이제 해가 지평선을 넘어가는 순간, 표면도 깜깜해집니다. (그림 c) 그렇다면 해가 지고 난 뒤엔 어떻게 되죠? 여전히 표면이 깜깜하겠죠? 표면에 전혀 빛이 닿지 않으니까요. 자, 그럼 이 현상을 그래프로 그려보면 어떻게 될까요? 법선과 해가 이루는 각도를 X축으로 두고 표면의 밝기를 Y축으로 하겠습니다. 여기서 Y축이 가지는 값의 범위는 0~1인데0은 표면이 아주 깜깜한 때를(0%), 1은 표면이 최고로 밝은 때(100%)를 나타냅니다.

그림 4.5. 관찰결과를 그려본 그래프

 


위 그래프에서 -90 ~ 90도사이의 그래프에 물음표를 달아둔 이유는 각도가 줄어듦에 따라 얼마나 빠르게 표면이 어두워지는지를 모르기 때문입니다. 이제 이 그림을 그림 4.3과 비교해 볼까요? 그림 4.3에서 결과가 0 이하인 부분들을 0으로 만들면 지금 만든 그래프와 꽤 비슷하네요? 차이점이라고는 -90 ~ 90도 사이에서 그래프가 떨어지는 속도가 조금 다르다 뿐이군요. 그렇다면 람베르트 아저씨가 표면이 어두워지는 속도를 아주 꼼꼼히 잘 관찰한 뒤에, 위 코사인 공식을 만들었다고 믿어도 될까요? 전 그렇게 믿고 있습니다. -_-

자, 그럼 람베르트 모델을 적용하면 코사인 함수 한 번으로 난 반사광을 쉽게 구할 수 있겠군요! 하지만 코사인 함수는 그다지 값싼 함수가 아니어서 쉐이더에서 매번 호출하는 것이 영 꺼림직합니다. 다른 대안이 없을까요? 수학책을 뒤적여 보니까 내적(dot product)이라는 연산이 코사인을 대신할 수 있다고 나오는 걸요?
 

θ = A와 B가 이루는 각도
| A |  = 방향벡터 A의 길이
| B |  = 방향벡터 B의 길이


A ∙ B = cosθ | A || B |


즉,

cosθ = (A ∙ B) ÷ (| A |ⅹ| B |);


위의 내적 공식에 따르면 두 벡터가 이루는 각의 코사인 값은 그 둘의 내적을 구한 뒤 두 벡터의 길이를 곱한 결과로 나눈 것과 같습니다. 여기서 두 벡터의 길이를 1로 만들면 공식을 더 간단히 만들 수 있습니다.
 

cosθ = (A' ∙ B')


두 벡터가 이루는 각의 코사인 값은 두 벡터의 내적과 같다는 군요. 근데 이렇게 저희 맘대로 벡터의 길이를 바꿔도 되는 걸까요? 이 질문을 다르게 표현하면, '난 반사광을 계산할 때 법선의 길이나 입사광 벡터의 길이가 중요한가요?'입니다. 전혀 그렇지 않지요? 두 벡터가 이루는 각이 중요할 뿐 벡터의 길이는 결과에 아무런 영향을 미치지 않습니다. 따라서 이 두 벡터의 길이를 각각 1로 만들어서 공식을 간단하게 만드는 게 훨씬 나아 보이는군요.[각주:7]
 
그럼 내적이 코사인 함수보다 값싼 연산인 이유를 살펴볼까요? 벡터 A의 성분을  (a, b, c)로 두고 벡터 B의 성분을 (d, e, f)로 두면 두 벡터의 내적을 이렇게 간단히 구할 수 있습니다.

A ∙ B = (a ⅹ d) + (b ⅹ e) + (c ⅹ f)


코사인 함수보다 훨씬 간단해 보이는 게 맞죠? 당장 코사인 함수를 구하라고 하면 머리부터 긁적이실 걸요? ^^

자, 그럼 이 정도면 난 반사광에 대해 충분히 설명을 드린 것 같으니 지금 배운 내용들을 까먹기 전에 곧바로 쉐이더를 작성해 보겠습니다.



p.s. 여기는 댓글 달릴때마다 이메일이 오지 않아서 답변을 빨리빨리 드리기가 쉽지 않군요. 제 개인 블로그에 포스팅 된 버전에 댓글을 다시는게 제일 답변이 빠릅니다. 오탈자 및 잘못된 내용 수정도 제일 빠릅니다.


포프 올림

  1. 특히 콘솔 하드웨어의 하드웨어 사양이 더 큰 문제입니다. [본문으로]
  2. 간접광까지도 다루는 조명모델을 전역조명모델(global illumination model)이라고 합니다. 반대로 직접광만을 다루는 조명모델을 지역조명모델(local illumination model)이라 합니다. [본문으로]
  3. diffuse 광은 아직도 용어정립이 잘 안되고 있습니다. 따라서 이 용어를 사용할 때마다 종종 영문 표기를 같이 하도록 하겠습니다. 다른 용어로는 산란광, 확산광 등이 있는데 난 반사광이 가장 적합한 것 같습니다. [본문으로]
  4. 이것이 뒤에서 살펴볼 정 반사광입니다. [본문으로]
  5. 표면이 완전히 매끈하더라도 난반사가 완전히 사라지는 경우는 극히 드뭅니다. 표면을 뚫고 들어간 뒤, 물체 내부에서 반사되는 빛도 있기 때문입니다. [본문으로]
  6. 법선(normal)이란 표면의 방위(orientation)를 나타내는 벡터입니다. 따라서 그림 4.2에서처럼 좌우로 평평한 평면의 법선은 위쪽으로 수직인 선이 됩니다. [본문으로]
  7. 이렇게 길이가 1인 벡터를 단위벡터(unit vector)라고 하며, 단위벡터를 만드는 과정을 정규화(normalize)라고 합니다. [본문으로]

[Bullet Physics] Bullet 물리엔진의 설치

물리 2011. 12. 22. 12:53 Posted by 알 수 없는 사용자

인사하는 페이지가 없어 간단하게 인사를 하겠습니다. 저는 물리 프로그래머 조성현입니다.
앞으로 Bullet엔진에 대해서 부족하지만 글을 올리고자 합니다. 오늘은 Bullet엔진에 대해서 간단하게 소개를 드리고, 설치 방법에 대해 간략하게 설명하겠습니다.

1) Bullet 물리엔진
Bullet은 오픈소스 물리엔진입니다.
오픈소스 물리엔진은 ODE, Newton도 있지만, Bullet이 현재 가장 활발하게 운영되고 있고, 소스도 C++ 스타일로 매우 깔끔합니다.  그리고 Havok과 구조가 유사해서, TryHavok과 같이 공부하신다면 쉽게 익힐수 있습니다.

2) 다운로드 및 설치
1) 소스 받기
http://code.google.com/p/bullet/downloads/list 에서 최신버전을 다운 받습니다. 현재 최신버전은 2.79입니다.

2)예제 실생하기
bullet-2.79/msvc/vs2010/0BulletSolution.sln 을 실행합니다.
아래와 같은 예제들을 확인 합니다.


3)내 프로젝트에 Bullet 라이브러리를 추가 하기
"bullet-2.79/lib"에서 아래와 같은 라이브러리가 있는지 확인 합니다.(예제를 실행하면 생성됨)


없으면 예제를 실행시키거나 아래 그림처럼 라이브러리만 따로 빌드 합니다.


라이브러리를 확인 후, 내 프로젝트에 아래의 그림 처럼 "bullet-2.79/src"를  Include 경로에 추가 합니다.


아래의 그림처럼 "bullet-2.79/lib"를 라이브러리 경로에 추가 합니다.


마지막으로 "BulletCollision_debug.lib, BulletDynamics_debug.lib, LinearMath_debug.lib"(디버그 모드)를 아래와 같이 추가 합니다.


위의 과정을 거치면 설치는 끝납니다. 다음에는 World구성과 RigidBody에 대해서 간략하게 설명하겠습니다.

'물리' 카테고리의 다른 글

Inertia Tensor(2)  (0) 2012.02.19
Inertia Tensor(1)  (0) 2012.02.13
RigidBody의 Restitution, Friction, Damping  (2) 2012.02.06
[Bullet Physics] RigidBody 만들기  (2) 2012.01.05