개요

 언리얼엔진의 물리연산은 Determinism(결정론적 동작)을 보장하고 있지 않지만 Physics Divergence(물리 연산의 오차 발산)을 막기 위해 어느정도 고려하고 있습니다. 특히 네트워크 동기화에서는 매 Frame마다 물리 연산이 조금이라도 달라지면 짧게는 수 Frame, 길게는 수 초 후의 결과가 완전히 달라지기 때문에 여러 구조적 개선을 지속적으로 진행해왔습니다. 이 글은 그러한 언리얼 엔진의 Physics를 Determinism 관점으로 오차 최소화를 어떻게 달성하였는지에 대해 정리한 글입니다.

 

언리얼엔진의 물리, 각 스텝 정리

[1] PrePhysics

Actor Tick (PrePhysics Group)

  • 입력 처리 / 이동 요청 확정
  • 물리에 들어가기 전 게임 상태 정리(게임 로직 기반 상태 업데이트)
  • Determinism 관점
    • Lockstep에서 "동일한 입력"이 보장되어야 하므로 이 단계가 핵심입니다.
    • 모두 같은 프레임에서 같은 입력을 받고 동일한 초기 상태를 맞춰야 Physics Divergence를 방지할 수 있습니다.

▼  (Barrier: 모두 완료 시 다음 단계)

 

[2] StartPhysics

  • Chaos Physics 엔진에 Step 시작 명령
  • Substep 준비
  • Collision BroadPhase 준비
  • Determinism 관점
    • Substep 개수, Step 간격, BroadPhase(충돌 탐색 알고리즘 순서)가 플랫폼과 스레드에 영향을 받지 않게 유지될수록 Determinism이 향상된다.
    • UE5에서 이곳의 안정성을 강화하고 있습니다.

 

[3] DuringPhysics

  • Substep 단위로 Physics Integration
  • 강체를 이동시킨 후, 충돌 감지, 반응 계산을 병렬로 수행
  • Solver 수행
  • Chaos Simulation 실행
  • Determinism 관점
    • 가장 Non-deterministic한 단계로 이 단계에서는 Divergence가 아래의 세가지 이유로 발생합니다.
      • Floating point차이
      • 멀티스레드 스케줄링 차이
      • Solver 순서 차이
    • 그렇기에 Lockstep 구현 시에는 Fixed Step, Fixed Seed, 동일 Substep을 강제로 통일합니다.

▼  (Barrier: 모든 물리 연산 완료 시 다음 단계)

 

[4] EndPhysics (PostPhysics)

  • Physics 결과를 게임 월드로 반영
  • Transform Sync
  • CharacterMovement / VehicleMovement 이동 보정(RootMotion/Prediction 보정)
  • PostPhysics Tick 실행
  • Determinism 관점
    • Physics가 만든 결과가 실제 게임 로직으로 흘러 들어가는 구간으로 Lockstep에서의 값들이 모든 클라이언트에 동일해야 이후 Prediction/Interpolation이 안정적으로 작동합니다.

 

[5] PostUpdate

  • During Physics의 모든 병렬 스레드를 대기하여 정리
  • Rendering 준비
  • Determinism 관점
    • 물리 결과가 렌더링 이전에 완전히 확정되는 곳입니다. 즉 Lockstep에서는 Frame Boundary가 되는 지점입니다.

Solver란?

 물리 연산 과정에서 발생한 힘, 속도, 충돌 등을 정리하여 게임에 맞게 보정하는 단계입니다. Chaos에서는 충돌 후의 위치, 속도, 반응을 반복 계산(Iterative Solve)하여 안정적인 결과로 수렴시키며, 최종적으로는 게임 월드에 반영하기 적합한 형태로 물리 상태를 정제하는 역할을 합니다.

 

Substep이란?

 하나의 물리 Frame을 여러개의 더 작은 시간 단위로 나누어 여러 번 시뮬레이션을 수행하는 구조입니다. 그로 인해 PhysicsThread는 고정된 시간 간격(Fixed Step)으로 더 촘촘하게 물리를 계산할 수 있습니다. 기본적으로 GameThread의 Tick 속도와 관계없이 진행됩니다. 하지만 PrePhysics와 PostPhysics가 GameThread에 종속되어 있기 때문에 시작과 끝으로 동기화 된다고 보시면 됩니다.

 Substep은 다음 목적에서 사용됩니다.

  • 빠르게 움직이는 객체의 충돌 누락 방지
  • 폭발, 충돌 등 입출력이 빠르게 변하는 상황에서 안정적인 결과 유지
  • Lockstep에서 모든 클라이언트가 동일한 수의 Substep을 수행하도록 강제하여 Determinism을 향상시킴

결론

 이처럼 물리 파이프라인의 각 단계를 통일된 방식으로 처리하여 Lockstep 구조에서 발생하는 Physics Divergence를 크게 줄일 수 있습니다. 또한 UE5가 지속적으로 강화하고 있는 Substep·Solver·Tick 구조는 Determinism을 향상시키는 방향으로 설계되어 있어 언리얼엔진에 있어 네트워크 게임에서의 물리 불일치 문제를 완화하는 핵심 축이 되었습니다.

 

 

 

GPU의 역사 - 1 : FFP에서 SIMD까지

그래픽카드의 한계 2000년대 초반의 GPU는 FFP(Fixed Function Pipeline)중심이었고, 지금처럼 복잡한 셰이더 기반 렌더링이 불가능했습니다. VRAM 용량도 32~64MB 수준이라 SSAA나 고급 조명 기법을 적용하기

chessire.tistory.com

 지난 시간 FFP에서 고전 SIMD의 등장까지 알아보았습니다. 이제 SIMD에서 SIMT로 넘어가던 DirectX9 ~ DirectX10 시절을 알아보도록 하겠습니다.

개요

 하나의 명령어로 여러 데이터를 동시에 처리하던 고전 SIMD는 구조적인 한계가 명확했습니다.

  • 데이터 길이가 workload에 맞지 않으면 cost 낭비가 발생
  • 스레드 개념이 없음.
  • 전체 SIMD가 stall 될 정도의 분기 처리
  • gather/scatter 미지원 => 다양한 메모리 주소 접근이 불가능

이 구조는 단순한 벡터 연산에는 강력하지만,
픽셀·조명·텍스처·조건문이 많은 실제 그래픽스 workload에는 치명적이었다.

 

 CPU는 SIMD 폭을 확장하고 ISA를 강화하는 방식으로 발전 방향을 잡았습니다.

  • 벡터 폭 증가
  • gather/scatter 지원
  • mask 연산 강화
  • 분기 최소화 등

즉, CPU는 SIMD를 더 강한 벡터 처리 장치로 발전시키는 방향을 택했다.

SIMT의 등장

 하지만 GPU는 완전히 다른길을 선택했습니다. 그것은 바로 SIMD를 Thread로 추상화하여 재해석하는 방법입니다. GPU는 수천 ~ 수만 개의 Pixel Fragment를 병렬 처리해야 했기 때문에 PC(ProgramCounter)를 Thread마다 하나씩 둘 수 없었습니다. PC를 Thread마다 하나씩 두면 면적과 전력소모가 폭발적으로 증가하기 때문이었습니다.

 그래서 GPU는 SIMD를 다음과 같이 재해석했습니다.

"SIMD lane을 Thread처럼 보이게 만들고,
프로그래머에게는 각 lane이 독립적인 스레드처럼 보이게 하자."

 

 이 방식이 바로 SIMT 모델입니다.

about SIMT

Warp란 무엇인가?

 CPU는 Thread마다 PC가 하나씩 있습니다. 그렇기 때문에 완전한 MIMD 실행 모델로 각자 다른 위치에서 다른 코드를 실행할 수 있습니다. 하지만 GPU의 Warp의 구조는 정반대입니다.

 

 예를 들어 Warp = 32 thread가 있다면,

32개의 스레드는 각자 독립적으로 보이지만,
Warp 전체가 PC 1개,
그렇기 때문에 32개의 스레드는 한번에 동일한 명령어만 실행 가능.

각 Thread는 고유한 Register 값을 갖지만 명령어 흐름은 공유.

 이렇게 동일한 명령어를 동시에 실행하는 한 단계가 Lockstep입니다.

Branch Divergence

 이 조건을 보면 Lockstep의 의미가 바로 드러납니다.

 아래와 같은 조건이 나왔다고 합시다.

if (x < 16)
  A 실행
else if(x < 32)
  B 실행

 

  • thread 0 ~ 15 =>  A 실행
  • thread 16 ~ 31 => B 실행

 이렇게 CPU 에서는 스레드 별로 명령어를 다르게 실행할 수 있기 때문에 각 스레드에서 A와 B를 나눠서 실행하게 됩니다. 하지만 GPU는 Warp는 하나의 PC만 있으므로 두 경로를 동시에 실행할 수 없습니다.

 

 그렇기에 SIMT는 다음과 같은 방식으로 처리합니다.

 Thread 32개를 컨트롤 하는 Warp의 PC를

  1. A 경로로 이동
    • true thread만 활성 (mask = on)
    • false thread는 비활성 (mask = off)
    • Warp 실행(A만 실행 됨)
  2. B 경로로 이동
    • false thread만 활성 (mask = on)
    • true thread는 비활성 (mask = off)
    • Warp 실행 (B만 실행 됨)

 이렇게 한 warp안에서 분기가 갈리면 warp는 A와 B가 직렬화 되어 순차적으로 두 번 실행합니다. 이게 바로 lockstep의 실행제약인 Branch Divergence 입니다. 고전 SIMD에서는 이러한 직렬화 때문에 Stall이 발생하였지만 Warp단위로 분리된 SIMT의 경우에는 MIMD처럼 동작하기 위해 Warp(Thread 묶음, SIMD Lane)를 다시 Thread로 추상화하여 다루는 매커니즘을 갖게 되었습니다.

맺음말

 SIMT의 등장은 고전 SIMD의 한계를 '극복'했다기보다는, 그래픽스라는 특수한 workload를 처리하기 위한 현실적인 타협이었습니다.

  • SIMD lane을 Thread처럼 추상화하여 프로그래머에게는 MIMD처럼 보이게 만들고
  • 내부에서는 여전히 lockstep 기반 SIMD의 효율을 유지하며
  • Massive Parallel Pixel Workload를 감당할 수 있도록 한 구조

 즉, SIMT는 "GPU스럽게 동작하는 MIMD의 환상"을 만들어낸 모델이라고 할 수 있습니다.

 Branch divergence는 여전히 존재하며 성능을 떨어뜨립니다. 하지만 FFP 시대에는 상상할 수 없었던 복잡한 조명, 그림자, 포스트 프로세싱, 물리 기반 셰이딩을 GPU가 처리할 수 있게 된 것도 결국 이 SIMT 모델 덕분입니다.

 다음 글에서는 이 SIMT 모델이 어떻게 구체적으로 구현되었는지, 그리고 DirectX10~11, UE3~UE4 시대를 지나면서 하드웨어와 셰이더 모델이 어떤 방향으로 진화했는지를 이어서 다뤄보겠습니다.

'Game Programming > Graphics' 카테고리의 다른 글

GPU의 역사 - 1 : FFP에서 SIMD까지  (0) 2025.11.28
레이트레이싱(ray tracing) 기법  (0) 2021.04.15
Direct3D9 랜더링파이프라인  (0) 2010.02.26

 

 

GPU의 역사 - 1 : FFP에서 SIMD까지

그래픽카드의 한계 2000년대 초반의 GPU는 FFP(Fixed Function Pipeline)중심이었고, 지금처럼 복잡한 셰이더 기반 렌더링이 불가능했습니다. VRAM 용량도 32~64MB 수준이라 SSAA나 고급 조명 기법을 적용하기

chessire.tistory.com

이전 글과 이어지는 게시글입니다.

 

UE2의 등장

 UE2는 2001년에 빌드 633 형태로 처음 공개되었습니다. 2001년 이전의 GPU는 대부분 FFP(Fixed Function Pipeline) 기반이었지만, DirectX 8 세대에 들어서면서 픽셀 셰이더와 버텍스 셰이더를 활용할 수 있게 되었습니다. UE2는 이러한 변화에 맞추어 GPU 가속 렌더링과 멀티플랫폼을 고려한 구조로 설계되었고, 이때부터 고정 함수 결과에 셰이더 출력을 조합(Combine) 하는 형태의 Material 처리 방식이 자리 잡기 시작했습니다. 이 과정에서 지금의 Material System으로 이어지는 초기 형태의 조합 기반 효과(= Add, Mul, Lerp 등을 활용한 간단한 Overlay 스타일 처리)가 사용되었고, 이후 언리얼 엔진 3에서 노드 기반 Material Editor로 확장되며 우리가 알고 있는 Overlay 계열 블렌딩이 정식 기능으로 자리 잡게 됩니다.

Rendering Pipeline

Fixed-Function Pipeline

 앞서 설명드린 것 처럼 DirectX8 이전 버전은 FFP 기반이었고, DirectX9 초기까지도 FFP를 기반한 Rendering Pipline형태였습니다. (위 이미지 참고) Diffuse Lighting, Lightmap 같은 연산은 여전히 하드웨어 단계에서 단계적으로 수행하였죠. 여기까지는 Programmable Shader가 개입할 수 없었습니다.

Execute Shader

 완전한 형태의 Programmable은 아니었지만 Pixel Shader Stage가 생기면서 UE2에도 부분적으로 Shader가 들어오기 시작했습니다. 조명 보정을 위한 NormalMap, Overlay에 쓰일 추가 색 계산까지, 드디어 Pixel Shader Stage가 하드웨어에 고정된  형식이 아닌 프로그래머가 작성한 코드를 통해 하드웨어에서 실행할 수 있게 된 것입니다.

Combine Process

 그렇게 Pixel Shader Stage에서 실행된 코드가 Combine Process를 타고 FFP의 결과물에 Overlay되게 됩니다. UE2 시절의 Overlay는 Add/Multiply 기반의 단순 조합이었음에도 언리얼엔진의 초기 머터리얼 시스템의 개념적 기반이 될 수 있었습니다. 즉, 텍스처 샘플링 결과와 셰이더에서 계산된 값을 다양한 방식으로 섞어 최종 색을 만드는 개념이 이때 자리를 잡기 시작한 것입니다. 이 개념은 UE3에서 노드 기반 머터리얼 시스템으로 확장되며 지금과 같은 형태까지 발전하게 됩니다.

Pixel Output

 그렇게 최종적으로 픽셀이 화면에 출력되게 됩니다. Pixel Output은 GPU 파이프라인의 End Point이지만, 그 이전 단계에서 FFP 연산, 쉐이더 계산, 조합 과정 등 다양한 처리가 누적되며 최종적인 화면이 구성됩니다. 즉, 우리가 보는 한 장의 화면은 여러 단계의 계산이 겹쳐 만들어진 결과물인 셈입니다.

Epic Games, Post Process 설명에서 발췌

Post Process Effect

 UE2 후반에 와서 카툰 렌더링(셀 셰이딩 + 외곽선 추출)을 비롯한 다양한 Post Process Effect가 가능해졌습니다. UE2당시의 Post Process는 단순히 화면 렌더링을 한번 더 샘플링 하는 단순 스크린 패스 수준이었지만 UE3에서 실질적인 체계가 확립되고 UE4에서 영화 렌더러 수준까지 진화할 정도의 무서운 약진이라 할 수 있습니다.

 

맺는 말

기술을 배울 때 그 변화의 흐름을 함께 이해하면 구조가 더 명확하게 잡히고, 어떤 설계 철학으로 발전해 왔는지도 선명하게 드러납니다. 언리얼 엔진은 이제 게임뿐 아니라 영화, 건설, 시뮬레이션 등 다양한 분야에 활용되는 범용 실시간 엔진이 되었고, 그중 UE2는 렌더링 시스템의 기반이 형성되던 중요한 시기였습니다.

 오늘은 UE2의 Overlay와 Post Process Effect를 간단히 정리해보았습니다. 다음 글에서는 UE3에서 본격적으로 확립된 머티리얼 시스템과 렌더링 변화에 대해 이어 설명하겠습니다.

'Game Programming > Unreal Engine' 카테고리의 다른 글

Unreal Engine Physics Determinism: Lockstep  (0) 2025.12.10

그래픽카드의 한계

 2000년대 초반의 GPU는 FFP(Fixed Function Pipeline)중심이었고, 지금처럼 복잡한 셰이더 기반 렌더링이 불가능했습니다. VRAM 용량도 32~64MB 수준이라 SSAA나 고급 조명 기법을 적용하기 어려웠죠. 이 글에서는 당시 GPU의 한계를 살펴보고, 어떻게 SIMD 기반의 Programmable Pipeline 으로 발전했는지 정리합니다.

 

MSAA (Multi-Sampling Anti-Aliasing)

MSAA vs EQAA 샘플 패턴 비교 이미지

Pixel Boundary

한 픽셀을 크게 확대한 사각형 틀. (픽셀은 점이 아니라 “면적”을 가진 사각형입니.)

Color Sample Location

픽셀 내부의 특정 지점(Point)에서 실제 색상(Color)을 샘플링하는 위치. 여러 Color Sample을 블렌딩하여 최종 픽셀 색을 만든다.

Coverage Sample Location

폴리곤이 픽셀을 얼마나 덮고 있는지(Coverage) 판단하는 지점. 각 샘플이 폴리곤 내부면 1, 외부면 0에 해당하며, 이를 평균내어 블렌딩 가중치(강도)로 사용한다. Color Sample보다 계산 비용이 훨씬 낮다.

 

 초기 GPU의 VRAM 용량이 32 ~ 64MB 하던 시절에는 픽셀 단위로 복잡한 처리를 수행할 수 없었습니다. 그래서 이미지 전체를 더 높은 해상도로 여러 번 렌더링하는 SSAA(Super-Sampling Anti-Aliasing)을 다운 샘플링을 하는 방식이 유일한 선택지였습니다. 해당 방식은 해상도 배율 x 샘플 수 만큼의 비용증가가 있었고, 이는 픽셀 셰이딩, 텍스쳐 샘플링, ROP(Blend / Depth / Color Write) 등 '픽셀 이후 단계'의 비용을 전부 O(S) 수준으로 폭증시켰습니다. 즉, 샘플 수만큼만 비용이 증가하는 것이 아니라, 렌더타겟 자체도 커지기 때문에 파이프라인의 대부분이 정비례로 비싸지는 구조였습니다.

 하지만 VRAM 용량이 어느정도 여유가 생기자, 픽셀 내부의 Color Sample과 Coverage Sample을 저장할 수 있는 구조가 가능해졌고, MSAA는 Pixel Shader는 한 번만 수행하고, Coverage Test와 Color Resolve만 샘플 수에 따라 반복하는 방식으로 SSAA 대비 큰 폭의 최적화를 달성했습니다.

 

Fixed Function Pipeline > Programmable Pipeline 의 과도기

FFP(Fixed Function Pipeline)

  • Transform & Lighting
  • Texture Stage State 기반 2~3단 텍스처 처리
  • 멀티패스 조명
  • DOT3 Bump Mapping(FFP LOD 바탕)
  • Lightmap 기반의 Static Lighting
  • UI, HUD, 간단한 머터리얼 > FFP로 렌더링

하지만 UE2 후반 버전은...

Programmable Pipeline 일부 적용

  • Shader-driven Material Effects
  • Normal Mapping
  • Specular Mask
  • Detail Normal Layer
  • Color Modulation
  • Hardware Skinning
  • NVIDIA/ATI 전용 Shader Path

이것이 가능해진 이유는 GPU의 주요한 변화 덕분.

Scalar ALU vs SIMD Lane

기존

 Scalar ALU에 단순한 microcode 실행기를 사용하여 float4연산은 4개의 스칼라 연산을 순차적으로 연산하도록 실행, 단지 GPU에 Scalar ALU를 많이 담아서 단순 병렬처리를 수행했을 뿐.

SIMD(Single Instruction Multiple Data stream)의 등장

하나의 명령어(Single Instruction)를 Scalar ALU의 묶음인 SIMD Lane에 전달하여 연산에 사용될 Multi-Data를 레지스터/버퍼(L1/L2, GPR)에 담아 연산하도록 실행

 

다음글

 

GPU의 역사 - 2 : SIMD에서 SIMT로, Branch Divergence

GPU의 역사 - 1 : FFP에서 SIMD까지그래픽카드의 한계 2000년대 초반의 GPU는 FFP(Fixed Function Pipeline)중심이었고, 지금처럼 복잡한 셰이더 기반 렌더링이 불가능했습니다. VRAM 용량도 32~64MB 수준이라 SSAA

chessire.tistory.com

 

0. 개요

 요즘 레이트레이싱에 관련된 작업을 시작하게 되어 정리차원에서 블로깅하게 되었습니다. 현재는 TinyRaytracer를 마개조 중이었고, 이번 글은 깃헙의 TinyRaytracer를 기반으로 레이트레이싱 기법에 대해 간략하게 설명해보도록 하겠습니다. 최적화 기법은 추후에 다룰 예정입니다. 다른 포스팅에서 확인 부탁드립니다. (fresnel equation이 추가되었습니다.)

 

 TinyRaytracer를 간략하게 설명드리면 다음과 같이 세 단계로 구성되어 있습니다.

 - 카메라에서 레이를 캐스팅한다.

 -> 레이를 추적하여 레이와 충돌된 물체의 diffuse, specular, reflect, refract 컬러를 계산하여 더한다.

 -> ppm파일로 추출한다.

 

1. 요소

 - 객체

Light : 그저 빛

Material : 재질

Object : 화면에 표시할 객체, 여기서는 간단하게 표현하기 위해 sphere만을 사용

 

 - 기능

render

 - ppm파일에 해당 씬을 그려주는 함수

cast_ray

 - 이를 캐스팅하고 충돌한 Object와 Light를 참조하여 Material 값을 계산한 후, 컬러값을 반환

scene_intersect

 - 레이를 씬과 충돌연산하여 hit point, hit normal, material 값을 반환

reflect

 - 물체에 반사된 레이의 컬러값을 반환

refract

 - 물체에 투과된 레이의 컬러값을 반환

fresnel

 - 레이의 입사각 및 물체의 Normal, 투과율에 따라 변하는 reflect와 refract의 비율을 반환
(이 부분은 tiny raytracer에는 없고, 퀄리티 업을 위하여 추가한 기능)

 

1. 객체 설명

 

 - Light

struct Light {
    Light(const Vec3f &p, const float i) : position(p), intensity(i) {}
    Vec3f position;
    float intensity;
};

Ambient light 입니다.

 

 - Material

struct Material {
    Material(const float r, const Vec4f &a, const Vec3f &color, const float spec) : refractive_index(r), albedo(a), diffuse_color(color), specular_exponent(spec) {}
    Material() : refractive_index(1), albedo(1,0,0,0), diffuse_color(), specular_exponent() {}
    float refractive_index;
    Vec4f albedo;
    Vec3f diffuse_color;
    float specular_exponent;
};

refractive_index : 투과율(1.0 : 공기, 1.3~1.5 : 유리구슬, 1.8 : 다이아몬드)

albedo : diffuse(x), specular(y), reflection(z), refraction(w)에 대한 0~1 수치입니다.

 

 - Object

struct Sphere {
	Vec3f center;
	float radius;
	Material material;

	Sphere(const Vec3f &c, const float r, const Material &m) : center(c), radius(r), material(m) {}

    bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const {
        Vec3f L = center - orig;
        float tca = L * dir;
        if (tca < 0) return false;
        float d2 = L*L - tca*tca;
        if (d2 > radius*radius) return false;
        float thc = sqrtf(radius*radius - d2);
        t0       = tca - thc;
		float t1 = tca + thc;
		if (t0 < 0) t0 = t1;
		if (t0 < 0) return false;
        return true;
    }
};

화면에 출력할 오브젝트입니다. 여기서는 편의상 구를 사용합니다.

ray_intersect함수의 경우, 레이의 original position, direction을 받아서 해당 오브젝트의 충돌검출(구와 직선의 충돌 알고리즘)과 함께 해당 오브젝트까지의 거리를 t0로 넘겨줍니다.

 

2. 기능 설명

 - render

void render(const std::vector<const sdf_model*> &models, const std::vector<Light> &lights) {
    const int   width    = 1024;
    const int   height   = 768;
    const float fov      = M_PI/3.;
    std::vector<Vec3f> framebuffer(width*height);

    #pragma omp parallel for
    for (size_t j = 0; j<height; j++) { // actual rendering loop
        for (size_t i = 0; i<width; i++) {
            float dir_x =  (i + 0.5) -  width/2.;
            float dir_y = -(j + 0.5) + height/2.;    // this flips the image at the same time
            float dir_z = -height/(2.*tan(fov/2.));
            framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), Vec3f(dir_x, dir_y, dir_z).normalize(), models, lights);
        }
    }

    std::ofstream ofs; // save the framebuffer to file
    ofs.open("./out.ppm",std::ios::binary);
    ofs << "P6\n" << width << " " << height << "\n255\n";
    for (size_t i = 0; i < height*width; ++i) {
        Vec3f &c = framebuffer[i];
        float max = std::max(c[0], std::max(c[1], c[2]));
        if (max > 1) c = c * (1. / max);
        for (size_t j = 0; j<3; j++) {
            ofs << (char)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j])));
        }
    }
    ofs.close();
}

카메라에서 레이를 발사하여 frame buffer에 기록하고, 그 데이터를 ppm 파일에 저장합니다.

 

 

 - cast_ray

Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const std::vector<Sphere> &spheres, const std::vector<Light> &lights, size_t depth=0) {
    Vec3f point, N;
    Material material;

    if (depth>4 || !scene_intersect(orig, dir, spheres, point, N, material)) {
        return Vec3f(0.2, 0.7, 0.8); // background color
    }

	Vec3f refract_color(0.f, 0.f, 0.f);
	// compute fresnel
	float kr;
	fresnel(dir, N, material.refractive_index, kr);
	// compute refraction if it is not a case of total internal reflection
	if (kr < 1) {
		Vec3f refract_dir = refract(dir, N, material.refractive_index).normalize();
		Vec3f refract_orig = refract_dir * N < 0 ? point - N * EPSILON : point + N * EPSILON;
		refract_color = cast_ray(refract_orig, refract_dir, models, lights, depth + 1);
	}

	Vec3f reflect_dir = reflect(dir, N).normalize();
	Vec3f reflect_orig = reflect_dir * N < 0 ? point - N * EPSILON : point + N * EPSILON; // offset the original point to avoid occlusion by the object itself
	Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, models, lights, depth + 1);

    float diffuse_light_intensity = 0, specular_light_intensity = 0;
    for (size_t i=0; i<lights.size(); i++) {
        Vec3f light_dir      = (lights[i].position - point).normalize();
        float light_distance = (lights[i].position - point).norm();

        Vec3f shadow_orig = light_dir*N < 0 ? point - N*EPSILON : point + N*EPSILON; // checking if the point lies in the shadow of the lights[i]
        Vec3f shadow_pt, shadow_N;
        Material tmpmaterial;
        if (scene_intersect(shadow_orig, light_dir, spheres, shadow_pt, shadow_N, tmpmaterial) && (shadow_pt-shadow_orig).norm() < light_distance)
            continue;

        diffuse_light_intensity  += lights[i].intensity * std::max(0.f, light_dir*N);
        specular_light_intensity += powf(std::max(0.f, -reflect(-light_dir, N)*dir), material.specular_exponent)*lights[i].intensity;
    }
    return material.diffuse_color * diffuse_light_intensity * material.albedo[0]
		+ Vec3f(1., 1., 1.)*specular_light_intensity * material.albedo[1]
		+ reflect_color * material.albedo[2] * kr
		+ refract_color * material.albedo[3] * (1 - kr);
}

레이를 발사하는 함수입니다. 먼저 장면에 충돌시켜 충돌된 위치(point)와 해당 point의 normal값을 구합니다. 그 후, 위 함수는 세 연산으로 나뉩니다.

 

첫번째는 레이 연산입니다. 레이 연산은 다시 세가지로 나뉘는데 fresnel equation, 반사(reflection), 투과(refraction입니다. 이 부분을 설명하기엔 길어지므로 아래의 각 파트에서 다루도록 하겠습니다.

 

두번째는 빛 연산입니다. 먼저 변수에 대한 설명을 진행하도록 하겠습니다.

diffuse_light_intensity는 분산광의 정도를 저장하는 변수입니다.

specular_light_intensity는 반사광의 정도를 저장하는 변수입니다.

light_dir는 빛이 diffuse light이므로 빛의 방향이 됩니다.

light_distance는 빛에서부터 point까지의 거리로 shadow casting을 할 때, 앞에 있는 물체를 검출하기 위해 사용할 값입니다. 앞에 물체가 있다면 light값을 적용하지 않습니다.

shadow_origin은 point의 위치를 EPSILON으로 보정한 값입니다. point의 앞에서 발사한 빛인지 뒤에서 발사한 빛인지를 내적으로 검출하여 EPSILON으로 보정하여 light_distance 비교연산을 할 때 오차를 줄여줍니다. light_distance 비교연산은 변수 설명이 끝난 후 설명하겠습니다.

shadow_pt는 빛에서 point로 레이를 발사하는 과정에서 부딪힌 point입니다. light_distance보다 짧은 곳에서 충돌했다면 point 앞에 물체가 있는 것으로 음영에 diffuse_light_intensity와 이 필요합니다.

tmpmaterial은 scene_intersect에 넣어주기 위한 용도로 TinyRaytracer에서는 사용하지 않습니다.

마지막으로 light_distance 비교연산을 진행하고 그 결과에 따라 light_intensity를 적용하게 됩니다. 빛에서부터 해당 point까지 scene_intersect를 진행하여 light_distance보다 짧은 거리에 위치한 물체가 있다면 light_intensity 연산을 하지않게 되는 것이지요.

 

세번째는 위 연산에 따라 색상을 혼합해주는 연산입니다. 여기서 주목해야할 부분은 reflect_color와 refract_color에 fresnel equation의 결과값인 kr을 적용해주는 부분입니다. 아래 fresnel편에서 자세히 다루도록 하겠습니다.

 

 

 - scene_intersect

bool scene_intersect(const vec3 &orig, const vec3 &dir, const std::vector<Sphere> &spheres, vec3 &hit, vec3 &N, Material &material) {
    float spheres_dist = std::numeric_limits<float>::max();
    for (const Sphere &s : spheres) {
        float dist_i;
        if (s.ray_intersect(orig, dir, s, dist_i) && dist_i < spheres_dist) {
            spheres_dist = dist_i;
            hit = orig + dir*dist_i;
            N = (hit - s.center).normalize();
            material = s.material;
        }
    }

    float checkerboard_dist = std::numeric_limits<float>::max();
    if (std::abs(dir.y)>1e-3) { // avoid division by zero
        float d = -(orig.y+4)/dir.y; // the checkerboard plane has equation y = -4
        vec3 pt = orig + dir*d;
        if (d>1e-3 && fabs(pt.x)<10 && pt.z<-10 && pt.z>-30 && d<spheres_dist) {
            checkerboard_dist = d;
            hit = pt;
            N = vec3{0,1,0};
            material.diffuse_color = (int(.5*hit.x+1000) + int(.5*hit.z)) & 1 ? vec3{.3, .3, .3} : vec3{.3, .2, .1};
        }
    }
    return std::min(spheres_dist, checkerboard_dist)<1000;
}

레이를 원점으로부터 특정 방향으로 진행시킬 때의 hit point와 해당 point의 normal값을 검출하는 함수입니다.

Spheres와 checkboard를 검출하게 되어있습니다.

 

 

 - reflect

Vec3f reflect(const Vec3f &I, const Vec3f &N) {
    return I - N*2.f*(I*N);
}

 반사는 위의 코드처럼 심플합니다. 오른쪽 상단의 그림처럼 입사각과 반사각이 같다는 점을 이용하여 해당 공식을 유도하게 됩니다. 입사각과 반사각이 같게되면 오른쪽 하단의 그림처럼 표현할 수 있게 되는데요. 여기서 I와 R은 아래와 같이 구할 수 있습니다.

\(\hat{I} = \hat{A} + \hat{B}\)

\(\hat{R} = \hat{A} - \hat{B}\)

여기서 B는 N을 이용해서 아래와 같이 표현할 수 있습니다.

\(\hat{B} = cos({\theta}) * \hat{N}\)

그러면 이렇게 전개가 가능합니다.

\(\hat{I} = \hat{A} + cos({\theta}) * \hat{N}\)

\(\hat{A} = \hat{I} - cos({\theta}) * \hat{N})

\(\hat{R} = \hat{I} - 2cos({\theta}) * \hat{N}\)

I와 N은 방향을 나타내는 값으로 Normalize가 되어있어 스칼라값이 0입니다. 그리하여 아래의 수식으로 치환이 가능해집니다.

\(\hat{R} = \hat{I} - 2(\hat{I}\cdot\hat{N}) \hat{N}\)

 

 

 - refract

Vec3f refract(const Vec3f &I, const Vec3f &N, const float eta_t, const float eta_i=1.f) { // Snell's law
    float cosi = - std::max(-1.f, std::min(1.f, I*N));
    if (cosi<0) return refract(I, -N, eta_i, eta_t); // if the ray comes from the inside the object, swap the air and the media
    float eta = eta_i / eta_t;
    float k = 1 - eta*eta*(1 - cosi*cosi);
    return k<0 ? Vec3f(1,0,0) : I*eta + N*(eta*cosi - sqrtf(k)); // k<0 = total reflection, no ray to refract. I refract it anyways, this has no physical meaning
}

refract는 snell's law에 의해 구현되었습니다.

 우선 \(cos i\)를 확인하여 광선이 매질 안에서 발사되는 것인지를 확인합니다. 만약 \(cos i\)가 0보다 작으면 매질 안에서 발사되는 것이므로 eta_i와 eta_t를 교환해 다시 refract를 진행해줍니다.

 매개변수 중 eta_t는 투과를 진행할 물체의 투과율로 영어로는 ior(index of refraction)라고도 부릅니다. eta_i는 광선이 있는 곳의 투과율입니다.

 snell's law는 refraction을 설명하는 수식으로 다음과 같습니다.

 

 

 

 - fresnel

void Fresnel(const Vector3f &I, const Vector3f &N, const float &ior, float &kr)
{
	float cosi = std::clamp(I.dot(N), -1.f, 1.f);
	float etai = 1, etat = ior;
	if (cosi > 0) { std::swap(etai, etat); }
	// Compute sini using Snell's law
	float sint = etai / etat * sqrtf(std::max(0.f, 1 - cosi * cosi));
	// Total internal reflection
	if (sint >= 1) {
		kr = 1;
	}
	else {
		float cost = sqrtf(std::max(0.f, 1 - sint * sint));
		cosi = fabsf(cosi);
		float Rs = ((etat * cosi) - (etai * cost)) / ((etat * cosi) + (etai * cost));
		float Rp = ((etai * cosi) - (etat * cost)) / ((etai * cosi) + (etat * cost));
		kr = (Rs * Rs + Rp * Rp) / 2;
	}
}

 

 

Result

 

References

www.scratchapixel.com/lessons/3d-basic-rendering/introduction-to-shading/reflection-refraction-fresnel

 

Introduction to Shading (Reflection, Refraction and Fresnel)

Introduction to ShadingReflection, Refraction (Transmission) and Fresnel Reflection and refraction are very common in the real world and can be observed every day. Glass or water are two very common materials which exhibit both properties. Light can pass t

www.scratchapixel.com

github.com/ssloy/tinyraytracer

 

ssloy/tinyraytracer

A brief computer graphics / rendering course. Contribute to ssloy/tinyraytracer development by creating an account on GitHub.

github.com

 

출처 : http://answers.unity3d.com/questions/458207/copy-a-component-at-runtime.html


1
2
3
4
5
6
7
8
9
10
11
12
T CopyComponent<T>(T original, GameObject destination) where T : Component
 {
     System.Type type = original.GetType();
     Component copy = destination.AddComponent(type);
     System.Reflection.FieldInfo[] fields = type.GetFields();
     foreach (System.Reflection.FieldInfo field in fields)
     {
         field.SetValue(copy, field.GetValue(original));
     }
     return copy as T;
 }
 
cs


Type.GetFiels

(https://msdn.microsoft.com/ko-kr/library/ch9714z3(v=vs.110).aspx)

 - 현재 타입에서 public 필드를 가져오는 함수입니다. [SerializeField]는 가져오지 않습니다.

Save

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// (1) 스크린샷용 카메라를 준비합니다.
screenShotCamera.gameObject.SetActive(true);
 
// (2) 화면 크기를 지정합니다.
Vector2 screenSize = new Vector2(Screen.width, Screen.height);
 
// (3) 저장할 이미지의 크기를 지정합니다.(화면 크기 그대로 저장을 원하면 screenSize로 대체하시면 됩니다.)
Vector2 imageSize = new Vector2(
    cat.pictureSize.x / cameraSize.x * screenSize.x,
    cat.pictureSize.y / cameraSize.y * screenSize.y);
 
// (4) 저장할 이미지의 Offset을 지정합니다.
Vector2 imageOffset = cat.pictureOffset;
imageOffset.x += cameraSize.x * 0.5f - transform.position.x;
imageOffset.y += cameraSize.y * 0.5f - transform.position.y;
 
// (5) OpenGL의 경우 y축이 Upwards이고, 나머지의 경우 y축이 Downwards입니다.
if (SystemInfo.graphicsDeviceType != UnityEngine.Rendering.GraphicsDeviceType.OpenGL2 &&
    SystemInfo.graphicsDeviceType != UnityEngine.Rendering.GraphicsDeviceType.OpenGLCore &&
    SystemInfo.graphicsDeviceType != UnityEngine.Rendering.GraphicsDeviceType.OpenGLES2 &&
    SystemInfo.graphicsDeviceType != UnityEngine.Rendering.GraphicsDeviceType.OpenGLES3)
    imageOffset.y = cameraSize.y - imageOffset.y;
 
imageOffset.x *= screenSize.x / cameraSize.x - imageSize.x * 0.5f;
imageOffset.y *= screenSize.y / cameraSize.y - imageSize.y * 0.5f;
 
// (6) RenderTexture에 screenShotCamera가 보고 있는 화면을 Render 합니다.
RenderTexture rt = new RenderTexture((int)screenSize.x, (int)screenSize.y, 32);
screenShotCamera.targetTexture = rt;
screenShotCamera.Render();
RenderTexture.active = rt;
 
// (7) RenderTexture를 Texture2D로 옮깁니다.
Texture2D cache = new Texture2D((int)imageSize.x, (int)imageSize.y, TextureFormat.ARGB32, false);
cache.filterMode = FilterMode.Bilinear;
cache.ReadPixels(new Rect(imageOffset, imageSize), 00);
 
// (8) 저장합니다.
byte[] bytes = cache.EncodeToPNG();
string filename = Application.persistentDataPath + "/filename.png";
System.IO.File.WriteAllBytes(filename, bytes);
 
// (9) 뒷정리합니다.
screenShotCamera.targetTexture = null;
RenderTexture.active = null;
Destroy(rt);
screenShotCamera.gameObject.SetActive(false);
 
cs
  1. 스크린샷 카메라를 준비합니다. MainCamera를 사용해도 무방하다면 MainCamera를 사용합니다.
  2. 화면 크기를 지정합니다.
  3. 저장할 이미지의 크기를 지정합니다. (특정 영역을 저장할 때 사용합니다. 그것이 아니라면 화면 크기와 동일하게 지정합니다.)
  4. 저장할 이미지의 Offset을 지정합니다.
  5. 참고 : http://chessire.tistory.com/entry/%EB%A0%8C%EB%8D%94%ED%85%8D%EC%8A%A4%EC%B3%90-%EC%A2%8C%ED%91%9C%EA%B3%84Render-Texture-coordinates
  6. RenderTexture에 screenShotCamera가 보고 있는 화면을 Render 합니다.
  7. RenderTexture를 Texture2D로 옮깁니다.
  8. 저장합니다.
  9. 뒷정리합니다.



Load

1
2
3
4
5
byte[] bytes = System.IO.File.ReadAllBytes(Application.persistentDataPath + "/filename.png");
Texture2D texture = new Texture2D(11, TextureFormat.ARGB32, false);
texture.filterMode = FilterMode.Bilinear;
texture.LoadImage(bytes);
Sprite sprite = Sprite.Create(texture, new Rect(00, texture.width, texture.height), new Vector2(0.5f, 0.5f));
cs



참고 : http://docs.unity3d.com/Manual/SL-PlatformDifferences.html



문제

오늘 스크린샷 기능 만들다가 Unity RenderTexture의 이상한 점을 발견했습니다.


바로 그래픽스 sdk에 따라 Coordinate system이 다르다는 사실...

참고 URL을 가보시면 이런 글을 확인하실 수 있습니다.



해결방법


1
2
3
4
5
if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.OpenGL2 ||
    SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.OpenGLCore ||
    SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.OpenGLES2 ||
    SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.OpenGLES3)
 
cs

위 조건일 때, y값을 반전시켜주시면 됩니다.

C++에서 C#함수를 호출하는 방법에 대해 포스팅하겠습니다.(곁다리로 C#에서 C++함수 호출하는 방법도...)


MonoPInvokeCallback를 사용하시면 됩니다.


in C# ↓

using UnityEngine;
using System.Collections;
using System.Runtime.InteropServices;
using AOT;

public class SomePlugin //어떤 플러그인입니다.
{
    public delegate void SomeCallback( string result ); // c++에서 호출해줄 Callback형입니다.

    public static extern void ConnectCallback (
SomeCallback someCallback); // c#에서 호출할 c++함수입니다.

    public SomePlugin()
    {
        ConnectCallbackSomeFunction ); // 생성자에서 c++함수를 호출합니다.
    }

    [MonoPInvokeCallback(typeof(
SomeCallback))] // 핵심입니다.
    public static void SomeFunction(string result)
    {
        //something...
    }
}


in C++ 

typedef void (*SomeCallback)(const char* result);
SomeCallback someCallback = NULL;

extern "C"
{
    void ConnectCallback(SomeCallback _someCallback) //c#에서 호출할 함수입니다.
    {
        someCallback = _someCallback;
    }
}

void SomeFunction(const char* result)
{
    if( someCallback != NULL )
        someCallback(result);
}



사용법은 함수포인터 쓰듯이 사용하시면 됩니다.다만 native code에서 액세스 할 수 있도록 MonoPInvokeCallback 속성을 적용해줘야합니다.

유니티에서는 비추천하는 방법입니다(함수 포인터가 주소값 접근이라 위험해서 그런건지 뭔지는 모르겠지만요). 추천하는 방법으로는 UnitySendMessage가 있는데 느린 특징을 가지고 있기에 저는 이 방법으로 해결했습니다.


Unity 빌드 시, 유의할 점

  1. Plugins 폴더
    • 빌드를 수행할 때, 자동으로 Plugins폴더 안의 Android, iOS 폴더안에 있는 "특정" 소스파일이나 리소스파일들을 프로젝트에 추가해줍니다. iOS같은 경우는 Header search path까지 연결해줍니다.
    • 문제는 한 프로젝트로 여러가지 빌드(서로 다른 플러그인을 사용하는)를 뽑을 때 문제가 된다는 것! 잘 삭제 해주지 않으면 쓸데없이 용량을 잡아먹게됩니다.(자동화할때 유의해주세요.)
  2. PostprocessBuildPlayer
    • Plugins 폴더에 넣어놓는다고 하더라도 "특정"파일만 넣는 성격때문에 누락되는 파일들이 있습니다. 손으로 넣어주시는 분들은 상관 없지만 자동화할 땐 엄청난 불편함으로 다가옵니다. 그럴때 이것을 사용합니다.(전 모듈로 mod_pbxproj.py를 사용합니다. 엄청 편해요~)
    • 문제는 Assets/Editor폴더 안에 있는 단 한개의 PostprocessBuildPlayer만 실행이 된다는것입니다. 사용할 PostprocessBuildPlayer들을 PostprocessBuildPlayer_*로 이름을 변경한 후, 메인 PostprocessBuildPlayer가 PostprocessBuildPlayer_*를 전부 찾아 실행해주면 됩니다.
    • 말로하니 복잡하네요. 아래의 소스코드를 참고해주세요.
===========================================================================

#!/usr/bin/python


import sys

import subprocess

import glob

import os


def main(argv):

    paths = glob.iglob( 'Assets/Editor/PostprocessBuildPlayer_*' )

    

    for path in paths:

        if os.path.splitext(path)[1] != '.meta':

            os.chmod( path, 0755 )

            subprocess.call( [os.path.realpath(path), sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]] )



if __name__ == "__main__":

    main(sys.argv)

===========================================================================


  • il2cpp
    1. 유니티에서 64bit지원을 위해 부랴부랴 내놓은 아이입니다. il2cpp로 빌드를 하시고 Architecture를 Universial 혹은 ARM64로 설정해 놓아야 64비트 지원이 됩니다.
    2. C#을 빌드하면 나오는 IL파일을 cpp파일로 변환해주는 역할을합니다. Classes 폴더 하위에 Native폴더가 생기네요. 그 안의 파일들을 둘러보긴 했지만... 여하튼 소스가 많이 생겼습니다.
    3. 문제점
      1. 일단 실행파일 용량이 늘어납니다.(소스량이 적다면 조금 늘어나거나 안늘어날 수도 있습니다.) 
      2. 컴파일 시간이 길어집니다.(Unity에서의 컴파일 시간은 의외로 느리지 않은데 xcode에서 빌드시 cpp파일을 전부 빌드해줘야하기 때문에 그로인해 컴파일 시간이 길어집니다.
      3. mono_domain같은 c++에서 c#함수를 가져다 사용할 수 있게해주는 함수를 사용하지 못합니다.
    4. 해결방안
      1. 늘어난 용량은 코드최적화 레벨을 올려주면(ex, strip assembly) 약간 줄어듭니다만 기대치는 낮습니다.
      2. 컴파일 시간은 그냥 감내해야된달까요....
      3. 해당 부분은 유니티에서 권장하는 UnitySendMessage를 사용하면 되지만 느립니다. Object와 함수명을 string으로 찾아서 사용하는 방식이기 때문에 빈번하게 불리는 환경이 아니면 이것을 사용해줍니다.
        1. 빈번하게 사용하는 환경이라면 MonoPInvokeCallback를 사용해줍니다.




+ Recent posts