
안드로이드에서의 그래픽 렌더링 원리와는 거리가 먼, 3D 렌더링 작업에 대한 제 나름의 이해를 거침없이 써내려간 포스트입니다.
SceneView 에 대한 설명 및 코드는 포스트 후반부에 있으니, 해당 키워드를 통해 접근하신 분께서는 과감하게 스크롤을 내려주셔도 좋습니다.
동기
재직 중인 회사에서 전자 지갑 서비스를 구축하게 되었습니다. 관련하여 다양한 기술적 시도들이 진행되고 있는데, 모바일 팀 회의에서는 3D 모델을 앱 내에 탑재하여 트렌디함을 갖추자는 의견이 주요 목표로 설정되어, 이번 기회에 늘 생각만 해왔던 3D 모델링에 도전할 수 있게 되었습니다.
접근
어떤 방식을 통해 3D 모델을 구현해야 할지 결정해야 했습니다. 가장 먼저 떠오른 것은 OpenGL 이었습니다. OpenGL 은 2D 및 3D 그래픽을 렌더링하기 위한 인터페이스로, 다양한 프로그래밍 언어와 운영체제에서 플랫폼 독립적으로 GPU 와 통신, 고품질 그래픽을 생성할 수 있게 합니다.
OpenGL 이 가장 먼저 떠올랐던 건, 안드로이드 공식 문서를 살펴 볼 때마다 눈에 밟혔기 때문입니다. 그 때 해당 문서도 읽어보긴 했어서, '안드로이드에서 3D 그래픽을 렌더링 하려면 OpenGL 이란 걸 써야하는구나' 라고 막연히 생각했던 것이 머릿속에 남아 있었습니다.
OpenGL ES로 그래픽 표시 | Views | Android Developers
OpenGL ES로 그래픽 표시 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Android 프레임워크는 매력적이고 기능적인 그래픽 사용자 인터페이스를 만들기 위한
developer.android.com
OpenGL 에 대해 처음 학습을 해보려고 했으나, 시작과 동시에 막히고 말았습니다. 생성형 AI 가 있으니 도움을 받아봤지만, 전체적인 흐름만 얼추 이해할 뿐, 작성된 여러 값들에 대한 자세한 이해가 없으니 무턱대고 사용할 수도 없었습니다. 얻고자 하는 결과는 얻을 수 있었으나, 그 뿐이었습니다.
일단, 코드 자체가 너무나 길었습니다. MVP Matrix(Model View Projection Matrix, 3D 좌표가 2D 평면의 좌표로 변환되는 세 가지 단계를 의미. 보통 CPU 에서 이 세 행렬을 하나로 곱해 GPU 로 전달), Vertex Shader(모델을 구성하는 각 정점의 위치를 계산), Fragment Shader(화면에 그려질 각 픽셀의 최종 색상을 결정. Vertex Shader 가 위치를 결정하면, 그 사이를 채우는 픽셀들에 대해 광원 효과, 질감, 명암 등을 계산하여 색상을 입힘) 등 제대로 학습하지 않으면 이해하기 쉽지 않은 복잡한 코드가 매우 많은데, 각 Shader 를 컴파일하고 링킹시켜주는 코드도 포함되며, 이러한 상황에서 상호작용을 위한 코드까지 있으니 도무지 감당하기 어려웠습니다.
'이런 코드를 직접 작성하는 사람이 있다니' 라는 생각이 들었는데, 곰곰히 생각해보니 요새 게임 개발도 모두 이미 완성된 엔진을 활용해서 개발한다는 이야기가 떠올랐고, '안드로이드에도 비슷한게 있지 않을까' 라는 막연한 생각이 들어 검색을 해봤습니다. 관련하여 Filament, SceneView 와 같은 키워드를 발견했고, 이에 대해 알아 본 뒤 사용하기로 결정하였습니다.
Filament, SceneView
GitHub - google/filament: Filament is a real-time physically based rendering engine for Android, iOS, Windows, Linux, macOS, and
Filament is a real-time physically based rendering engine for Android, iOS, Windows, Linux, macOS, and WebGL2 - google/filament
github.com
Filament 는 구글이 만든 고성능 그래픽 엔진입니다. 실시간 PBR(Physically Based Rendering) 엔진으로, 안드로이드 뿐만 아니라 iOS, Windows, 웹 등 다양한 플랫폼을 지원합니다. 명칭의 Physically 라는 부분만으로 물리 기반 엔진임을 파악할 수 있는데요.
빛이 물체 표면에 반사되는 물리적 법칙을 정교하게 계산하여, 모바일에서도 실사와 같은 재질감을 표현할 수 있습니다. 또한, 모바일 기기의 제한된 리소스 상황에서도 최적의 성능을 내도록 설계되었습니다. 3D 모델의 렌더링, 조명 계산, 그림자 처리 등 가장 밑단의 그래픽 연산을 담당합니다.
GitHub - SceneView/sceneview-android: 3D and AR for Android using Jetpack Compose and Layout View, powered by Google Filament an
3D and AR for Android using Jetpack Compose and Layout View, powered by Google Filament and ARCore - SceneView/sceneview-android
github.com
SceneView 는 Filament 를 기반으로 구축된 고수준 3D 프레임워크입니다. 과거 구글이 유지보수 했던 Sceneform 의 연장선상에 있는 프레임워크라고 생각하시면 좋습니다. Filament 역시 직접 사용하려면 OpenGL 처럼 매우 복잡한 코드를 작성하게 되는데요. SceneView 는 이를 큰 학습 없이 쉽게 사용할 수 있도록 최적화, 및 추상화한 인터페이스입니다.
Physically Based Rendering
Filament 에 대한 기초적인 이해를 위해 Physically Based Rendering 에 대해 먼저 학습했습니다. 이는 현대 3D 그래픽스의 표준으로, 빛의 물리적 성질을 수학적으로 모델링하여 사물의 외형을 현실감 있게 표현하는 기술입니다.

위 이미지는 제가 어릴적 플레이했던 게임들입니다. 출시된지 꽤 오래된 게임들이라, Filament 와 같은 Physically Based Rendering 기술이 사용되지 않았습니다. 이들은 순서대로 카툰 렌더링, 전통적 셰이딩(Blinn-Phong), 2.5D 스프라이트(Pre-rendered Sprites) 방식으로 렌더링됩니다.
이 중 빛의 물리적 성질에 대한 이해가 적용된 것은 전통적 셰이딩 방식 뿐입니다. 물론 그마저도 제대로 구현된 것은 아니고 흉내 낸 수준인데, 광원의 위치와 모델의 위치 및 방향에 따라 강조되는 정도만 다르게 수식이 적용되어 있습니다. '광원에 따라 보이는 것이 조금씩 다르구나' 라는 느낌을 받을 수 있습니다.(물론 당시에는 훌륭한 그래픽이었지만!)
전통적 3D 렌더링 방식에서는 물체가 빛을 받으면 본래 가진 색보다 더 밝아지거나, 반사광이 들어온 빛보다 더 강해지는 등 물리적으로 불가능한 표현이 많았습니다. 그래서 특유의 번들거리는 플라스틱 느낌이 강했고요. 물체들은 각 소재의 속성을 가진 3D 모델이 펼쳐진 텍스처 이미지로 존재하고 이것을 폴리곤에 덮어 씌우는 방식으로 렌더링됩니다.
즉, 현대적 3D 그래픽스와는 거리가 있다고 할 수 있습니다. 특히 디아블로2(리마스터 이전)의 경우, 3D 모델을 여러 방향으로 돌려가며 2D 로 다시 렌더링한 거라, 보여지는 광원 효과는 실시간으로 적용되는게 아닌 미리 만들어진 결과물을 연속적으로 보여준 방식이기에 '3D 그래픽 게임이다' 라고 단정짓기 어렵기도 합니다.

과도기에 있었던 작품들의 경우, 물리적 반사의 가능성을 엿볼 수 있는데요. 엘더스크롤3 모로윈드의 경우 2002년에 출시된 작품이고, 여전히 전통적 셰이딩 방식을 사용했지만, 물체의 각 정점에서 빛의 강도를 계산하고 그 사이를 보간하여, 광원에 대한 이해를 플레이어에게 전달할 수 있었습니다. 어두운 공간에 등불이 있거나 횃불을 꺼내들면 주위가 밝아지는게 대표적인 예시입니다.
정점 조명의 한계가 해당 이미지에도 보여지는데요. 캐릭터 전체가 부드럽게 밝아지지 않고 광원을 직접적으로 받는 오른쪽 어깨만 밝아지고, 나머지 부분은 밝아지지 않습니다. 이는 픽셀 단위가 아닌 정점 단위로 빛을 계산하기 때문에, 면의 중간이 어색하게 꺾이는 현상이 발생하는 것입니다.

최근에 정말 재미있게 플레이했던 게임인데요. 몬스터헌터 월드의 경우 Physically Based Rendering 이 적용되었으며, 이에 따라 빛 표현이 매우 사실적으로 나타납니다. 맵 전체에 보여지는 광원은 물론이고, 캐릭터나 적이 내는 빛, 심지어는 캐릭터 복장에 있는 작은 등불에도 다양한 오브젝트가 실시간으로 반응하여 렌더링됩니다.
포스팅 주제는 3D 모델링인데 빛 얘기만 늘어놓은 것은, 사실 3D 모델링에서 가장 중요한 것이 빛, 즉 광원이기 때문입니다. 우리가 무언가를 본다는 것은 물체에 반사된 빛을 보는 것이며, 아무리 폴리곤이 완벽해도 빛이 없으면 평면처럼 보이게 됩니다.
평면에서의 '입체' 라는 것은 '밝기 차이' 가 만든 착시에 불과합니다. 실물 세계의 요철(凹凸, 오목하고 볼록한 상태)은 물질의 울퉁불퉁함이 존재하기 때문에 착시가 아니지만, 우리가 보는 화면은 모두 평면이므로, 평면에서 우리가 인식하는 요철은 광원에 의한 음영, 그림자, 반사광을 통해 만들어집니다.(폴리곤에 의한 요철은 요철이라기보다 구조에 가깝습니다) 그리고 이 요철은 사물의 재질을 의미합니다.
그러므로 3D 모델링의 질적 우수는 광원 처리에 있다고 말할 수 있으며, Physically Based Rendering 은 빛의 물리적 성질을 수학적 계산을 통해 실제와 매우 유사하게 구현할 수 있도록 하는 3D 렌더링 기술을 제공합니다.

Physically Based Rendering 이 빛을 이용해 실제와 같은 재질을 표현하는 그 원리에 대한 이해를 위해서는 BRDF(Bidirectional Reflectance Distribution Function) 에 대한 이해가 필수적이었습니다. BRDF 는 이방성 반사 분포 함수로, 광원으로부터 전달된 빛이 불투명한 표면에서 어떻게 반사되는지를 네 개의 변수(입사각 2개, 반사각 2개)로 정의하는 함수입니다.
첨부한 그림은 BRDF 의 원리를 한 눈에 보여주는데, L(Light, 입사광) 과 V(View, 시선 방향), N(Normal, 표면 법선) 이 있을 때, N 을 기준으로 하는 L 의 방위각과 고도각, 그리고 V 를 기준으로 하는 방위각과 고도각 사이의 상관 관계를 정의합니다. 즉, BRDF 는 특정 방향에서 입사한 빛이 물체의 표면에서 반사되어 다른 특정 방향으로 얼마나 전달되는지를 나타내는 수학적 모델입니다.

재질 및 표면이 거칠거나 꺾인 정도에 따라 반사되는 빛의 양이 조절되도록 설계되어 재질이 사실적으로 표현됩니다. 즉, Physically Based Rendering 은 물체의 재질에 따른 빛의 반사량을 조절하여 관찰자의 시점에 따른 명암 차이를 통해 더욱 사실적인 3D 렌더링을 가능케하는 원리입니다.
저도 그래픽을 따로 공부해 본 적이 없어 개념만 이해하고 있는 정도이니, 자세한 내용이 궁금하신 분들은 BRDF 위키피디아에서 학습해보시는 것을 추천드립니다.
Three Dimensions to Two Dimensions
BRDF 는 광원에서 전달된 빛이 오브젝트의 표면에 닿은 이후 반사되는 빛의 반사량을 방위각에 따라 조절하는 것인데, 해당 오브젝트를 바라보는 방위각이 달라짐에 따라 그 반사량도 달라진다는 것은 곧 오브젝트에 요철이 있음을 유추할 수 있게 합니다. 이 요철을 구성하는 구성 요소가 바로 그 유명한 폴리곤(Polygon) 입니다.
폴리곤은 3차원 공간에서 평면을 구성하는 최소 구성 요소를 의미합니다. 3차원 공간의 정점(x, y, z) 3개로 만든 삼각형이며, 폴리곤을 대량으로 이어 붙여 하나의 구조를 형성할 수 있습니다. 이를 폴리곤 메쉬(Polygon Mesh) 라 칭합니다.

안드로이드도 그렇고, 다른 프로그램도 그렇고, 우리가 보는 대부분의 화면은 2D 평면 화면입니다. 다들 잘 아시다시피 화면은 픽셀로 구성되며, 폴리곤 메쉬 데이터가 모니터 상의 픽셀로 변환되어 그려지는 과정을 그래픽스 렌더링 파이프라인(Graphics Rendering Pipeline) 이라고 합니다.
그래픽스 렌더링 파이프라인의 과정을 간략하게 설명하면 다음과 같습니다.
- Vertex Processing (정점 처리) - 3D 모델을 구성하는 각 정점들의 위치를 계산
- Transformation - 모델 자체의 좌표계를 공간 내 좌표로 옮기고, 다시 우리가 보는 View 의 시점으로 변경
- Projection - 3D 공간의 좌표를 2D 평면 좌표로 변환 (멀리 있는 것은 작게, 가까이 있는 것은 크게 보이도록 원근법을 적용)
- Rasterization (래스터화) - 폴리곤 메쉬를 픽셀로 변환
- 3D 공간상에 배치된 폴리곤이 화면에서 어떤 픽셀에 그려질지 결정
- 벡터 형태의 수학적 데이터가 모니터의 최소 단위인 픽셀(Fragment) 데이터로 분할
- Fragment / Pixel Processing (프래그먼트 처리) - 분할된 각 픽셀에 어떤 색상을 입힐지 결정 (PBR 이 사용되는 단계)
- Texture Mapping - 폴리곤 표면 상태를 결정 (다양한 매핑 방식이 사용됨)
- Lighting / Shading - 빛의 방향과 세기를 계산하여 그림자를 만들거나 표면의 입체감을 표현
- Output Merging (출력 병합) - 모든 계산이 끝난 픽셀들을 최종적으로 화면에 그리기 전 정리
- Depth Test - 여러 오브젝트가 겹쳐 있을 때, 카메라에서 가장 가까운 물체가 무엇인지 판단하여 가려진 픽셀은 렌더링 제외
- Alpha Blending - 반투명한 오브젝트가 있다면 뒤의 색상과 섞어 렌더링
해당 과정이 모두 포함된 영상은 아니지만, 3D 그래픽 처리에 대한 이해도를 높이기에 좋은 영상이 있어 소개합니다.
앞서 쭉 언급해왔던 Physically Based Rendering 은 3단계인 Fragment / Pixel Processing 단계에서 적용되며, 폴리곤 메쉬에 광원이 전달하는 빛이 밝기 차이, 음영, 그림자를 만들어 입체감을 느낄 수 있게 됩니다.
연산량 이슈와 최적화
다만, 폴리곤 메쉬에는 치명적인 문제가 있는데요. 폴리곤 메쉬를 구성하는 폴리곤의 수가 많을수록 폴리곤 메쉬는 더 부드러워지므로, 부드러운 폴리곤 메쉬를 구성하기 위해서는 말 그대로 천문학적인 수의 폴리곤이 필요하게 됩니다. 애초에 하나의 폴리곤은 정점 3개를 필요로 하고, 이후 과정에도 굉장히 많은 연산이 진행되므로, 부드러운 폴리곤 메쉬를 렌더링하는 작업은 그래픽 연산을 담당하는 GPU 에 큰 부하를 주게 됩니다.

부드러운 3D 모델과 이를 렌더링하는 비용은 트레이드-오프 관계에 있어, 기기 성능이 좋지 못했던 과거에는 위 이미지처럼 폴리곤 구조가 훤히 드러나는 적나라한 모델링을 할 수 밖에 없었습니다.(그럼에도 불구하고 버츄어 파이터1은 당대의 혁신적인 그래픽을 보여주는 역사적인 작품으로 남아 있습니다.)

급격한 기술의 발전에 따라, 표현할 수 있는 그래픽 품질의 상한도 당연히 늘어났습니다. GPU 자체의 성능이 비약적으로 좋아지면서, 훨씬 더 크고, 복잡하고, 섬세한 폴리곤 메쉬를 렌더링할 수 있게 되었습니다. 버츄어 파이터1 의 캐릭터와 비교하면 어떤가요? 상상할 수 없을만큼 많은 폴리곤이 들어갔을 겁니다.
하지만, 아무리 기기 성능이 좋아져도, 흑룡의 비늘 하나에 수천, 수만 개의 폴리곤이 필요하다고 가정하면 흑룡 한 마리를 모델링 하기 위해 말 그대로 천문학적인 수의 폴리곤이 필요하게 됩니다. 흑룡 뿐 아니라 플레이어 캐릭터 넷, 맵 요소 등, 크게 발전한 현대 GPU 로도 불가능에 가까운 렌더링 작업이 매 프레임마다 진행되어야 하므로, 당연히 쾌적하게 플레이할 수 없을 겁니다.
이러한 문제를 해결 하기 위해 다양한 매핑 방식이 개발되었습니다. 그 중 가장 유명한 것은 단연코 Normal Mapping(법선 매핑) 입니다. Normal Mapping 은 모델에 특수한 텍스처 파일을 덮어씌워 적은 수의 폴리곤(Low-Poly)으로도 마치 수만 개의 폴리곤(High-Ploy)을 사용한 것 같은 정교한 입체감과 디테일을 렌더링하는 기하학적 눈속임 기법입니다.
단순히 폴리곤 메쉬에 재질감이 그려진 텍스처 파일을 적용하는 것이 아니라, 픽셀 단위로 각기 다른 방위의 법선 데이터가 적용된 텍스처 파일을 덮어 씌웁니다. 이를 통해 GPU 는 실제 모델 자체의 법선이 아니라 Normal Map 에 있는 가상의 법선을 기준으로 빛을 계산하게 됩니다.

Normal Mapping 에 대한 설명이 가장 잘 표현되어 있는 이미지입니다. (b) 모델은 78,642개의 폴리곤으로 이루어져 있는데, 이를 768개의 폴리곤으로 이루어진 (c) 모델을 만든 뒤, 여기에 (a) Normal Map 텍스처를 적용하면 768개의 폴리곤으로 78,642개의 폴리곤으로 이루어진 모델과 비슷한 퀄리티의 3D 모델을 구성할 수 있습니다. Normal Map 텍스처는 다음과 같은 이미지로 표현됩니다.

Normal Map 텍스처 이미지는 보통 푸른 빛을 띄는데요. 이는 2차원 평면의 좌표 x, y 에 대한 법선 데이터 x, y, z 를 이미지 픽셀 x, y 에 대한 RGB 값에 대응시키기 매우 좋은 구성이며, 텍스처 작업 특화 GPU 구성 요소인 Texture Mapping Unit 이 이미지 데이터를 매우 효율적으로 핸들링할 수 있기 때문입니다.
Normal Mapping 에 대해서는 Normal Mapping 위키피디아에 잘 소개가 되어 있습니다. 또한 Normal Mapping 외에도 아주 다양한 최적화 기술이 개발되었으니, 궁금하신 분들은 관련하여 더 학습하시는 것도 좋을 것 같습니다.
모바일 환경의 경우, 당연히 데스크톱 환경에 비해 기기 성능이 현저히 떨어질 수 밖에 없습니다. '2D 애플리케이션 화면도 최적화가 제대로 안 되면 버벅거리기 십상인데, 복잡한 연산이 필요한 3D 애플리케이션이 이렇게 부드러울 수 있나?' 라는 생각이 들어 찾아보니 다양한 최적화 솔루션이 있어서 참 흥미롭다는 생각을 했네요. 기회가 된다면 조금 더 깊게 학습해봐도 좋을 것 같습니다.
SceneView 로 3D 모델 렌더링하기
SceneView 는 glb 파일을 통해 3D 모델을 불러올 수 있는데, glb 파일은 하나의 파일에 모델의 폴리곤 메쉬, 재질, 텍스처 등의 정보가 모두 포함되어 있어 매우 간편하며, 데이터를 바이너리 형태로 저장하므로 3D 모델을 JSON 텍스트로 저장하는 glTF 보다 경제적입니다. 또한, GPU 가 이해하기 쉬운 구조로 되어 있어 이를 파싱해내는 속도도 매우 빠릅니다.
일단 디자인팀과 협의된 부분은 아니라서, 무료로 공개된 glb 파일을 사용하였습니다.
val engine = rememberEngine()
val modelLoader = rememberModelLoader(engine)
val cameraNode = rememberCameraNode(engine).apply {
position = io.github.sceneview.math.Position(z = 8f)
}
먼저, Engine, ModelLoader, CameraNode 를 선언합니다. Engine 은 Filament 의 복잡한 설정을 일일이 건드리지 않아도 되도록 Filament 엔진을 효율적으로 관리합니다. 내부는 Singleton 으로 구성되어 앱 전체에서 하나의 인스턴스를 공유합니다. 하드웨어 리소스를 직접 제어하기 때문에, 사용이 끝나면 반드시 destroy() 메서드를 호출해야 합니다.
ModelLoader 는 이름에서 알 수 있듯, 3D 모델 파일을 읽어 들여 엔진이 이해할 수 있는 객체인 Entity 로 변환합니다. 단순히 파일을 불러오는 것 이상으로, 모델 안에 포함된 폴리곤 메쉬, 재질, 계층 구조 등을 SceneView 내부에서 사용할 수 있도록 구성하는 복잡한 과정을 담당합니다.
CameraNode 역시 이름이 매우 직관적인데요, 실제 시점을 제어하는 객체입니다. SceneView 는 기본적으로 하나의 CameraNode 를 제공하는데, 저는 별도로 선언하여 사용하였습니다.
val modelNode = rememberNode {
ModelNode(
modelInstance = modelLoader.createModelInstance("models/coin.glb"),
scaleToUnits = 1.0f
).apply {
rotation = io.github.sceneview.math.Rotation(x = 90f, y = 0f, z = 0f)
isEditable = true
isRotationEditable = true
isScaleEditable = true
}
}
렌더링할 3D 모델을 선언합니다. ModelLoader 를 통해 asset 에 준비한 glb 파일을 불러 올 수 있습니다.
LaunchedEffect(metallicFactor, roughnessFactor, rgb) {
modelNode.modelInstance.materialInstances.forEach { materialInstance ->
materialInstance.setParameter("metallicFactor", metallicFactor)
materialInstance.setParameter("roughnessFactor", roughnessFactor)
materialInstance.setParameter("baseColorFactor", rgb[0], rgb[1], rgb[2])
}
}
ModelLoader 를 통해 구성한 3D 모델의 속성을 변경할 수 있습니다. 별도로 enum class 가 선언되어 있지 않아서, 파라미터 명은 직접 String 으로 작성하여 넘겨주어야 합니다.
Box(modifier = Modifier.fillMaxSize()) {
Scene(
modifier = Modifier.fillMaxSize(),
engine = engine,
modelLoader = modelLoader,
cameraNode = cameraNode,
childNodes = listOf(modelNode),
onViewCreated = {
this.view.isPostProcessingEnabled = true
},
onViewUpdated = {
view.bloomOptions = this.view.bloomOptions.apply {
enabled = true
strength = bloomStrength
}
this.indirectLight?.intensity = sunIntensity
}
)
}
마지막으로, 3D 모델을 화면에 그려내기 위해 Scene() 메서드를 호출합니다. 모든 코드가 준비되었습니다. 보시는 바와 같이, SceneView 는 Jetpack Compose 개발 환경에서 매우 심리스하게 사용할 수 있습니다. Jetpack Compose 로 UI 를 구현한 경험이 많은 분들은 정말 쉽게 코드를 작성할 수 있으리라 생각합니다. 아래 영상은 코인 모델을 불러 와 여러 파라미터를 조절하여 적당한 금색 코인을 그려내는 과정이 담겨있습니다.
Normal Mapping 적용하기

Normal Mapping 에 관한 파트에 첨부되어 있던 Normal Map 을 코인에 적용해보았습니다.
val context = LocalContext.current
val normalTexture = remember(engine) {
val options = BitmapFactory.Options().apply { inScaled = false }
val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.normal_map_bulls_eye, options)
val texture = com.google.android.filament.Texture.Builder()
.width(bitmap.width)
.height(bitmap.height)
.sampler(SAMPLER_2D)
.format(RGBA8)
.levels(1)
.build(engine)
val buffer = allocateDirect(bitmap.byteCount)
bitmap.copyPixelsToBuffer(buffer)
buffer.flip()
texture.setImage(
engine, 0,
PixelBufferDescriptor(
buffer,
RGBA,
UBYTE
)
)
texture
}
val normalSampler = remember {
TextureSampler(
MinFilter.LINEAR_MIPMAP_LINEAR,
MagFilter.LINEAR,
WrapMode.REPEAT
)
}
Normal Map 을 획득하여, Bitmap 으로 전환합니다. 이 때, 반드시 Bitmap 의 inScaled 속성을 false 로 설정하여야 합니다. Normal Map 은 단순한 색상이 아니라 방향에 대한 데이터를 담고 있기 때문에, 안드로이드 시스템이 해상도에 맞춰 이미지를 임의로 키우거나 줄이면 데이터가 왜곡될 수 있기 때문입니다. 이후 allocateDirect() 메서드를 통해 ART 가 아닌 네이티브 메모리에 데이터 영역을 할당합니다.
val modelNode = rememberNode {
ModelNode(
modelInstance = modelLoader.createModelInstance("models/coin_with_normal_map.glb"),
scaleToUnits = 1.0f
).apply {
rotation = io.github.sceneview.math.Rotation(x = 90f, y = 0f, z = 0f)
isEditable = true
isRotationEditable = true
isScaleEditable = true
}
}
Normal Mapping 을 적용하기 위해서는, glb 파일 내부에 Normal Mapping 을 위한 슬롯이 마련되어 있어야 하는데요. 이는 Blender 에서 설정할 수도 있고, 해당 기능을 제공하는 웹사이트도 있어 생각보다 편하게 준비할 수 있습니다.
LaunchedEffect(modelNode.modelInstance) {
modelNode.modelInstance.materialInstances.forEach { it.setParameter("normalMap", normalTexture, normalSampler) }
}
Normal Map 이 런타임에 변경되는 경우는 거의 없으므로, 최초 구성 시 한 번만 적용할 수 있도록 구현합니다. 아래 영상은 기존 코인과 같은 3D 모델을 사용하였지만, Normal Mapping 이 적용되어 실시간으로 빛이 이전과 다르게 반사되는 모습을 촬영한 영상입니다.
후기
언젠가는 3D 모델링에 도전해보고 싶었는데, 이번 기회에 경험할 수 있어 참 좋았습니다. 과정 중에 Android Graphics Shading Language 라는 또 다른 흥미로운 주제도 알게 됐는데, 이 또한 학습해보면 참 좋겠습니다.
코드를 작성하고 개념을 익히는 데에 AI 서비스를 적잖이 사용했는데요. 요즘들어 AI 서비스들이 눈부신 혁신을 이루니, 다양한 분야에 대한 어프로치가 매우 쉬워져서 개발하기 참 편해진 것 같습니다. 빌드가 안 되거나 사소한 오류가 있을 때 구글 검색 하고 스택오버플로우 검색하고 하느라 하루 이틀 날려먹은 날도 많았는데.. 요새 이런 문제는 정말 10초 이내에 다 해결되는 것 같아서, 사실 너무 편합니다.
근데 그러다보니, 조금만 모르는게 생겨도 AI 한테 쪼르르 달려가게 되는 것 같더라고요. 답을 엄청 금방 내어주니까요. 사실 별다른 노력 없이 답을 구할 수 있어서 편한 건 맞는데, 그만큼 한 문제 해결하고 잠시 쉬러 갈 때 느끼는 성취감 자체는 많이 줄어든 것 같아요.
결국 중요한 건 본질이라고 생각합니다. 앞으로 AI 는 더 발전할 테고, 개발자들은 더 적극적으로 AI 를 활용해서 일을 하게 될텐데(개인적인 생각이지만 코드는 AI 가 짜고 검수를 개발자가 하는 형태가 되지 않을까 싶습니다), 본질에 대한 이해가 없다면 AI 가 작성해서 뱉어 낸 코드에 대한 건강한 비판을 할 수 없게 되니까요.
아무튼 간에, Blender 를 만질 줄 아시거나 glb 파일을 구할 수 있으시다면 한 번 쯤 SceneView 를 활용하여 3D 모델링에 도전해보시는 걸 추천드립니다. 어떠실지 모르겠지만, 확실히 저는 UI 나 3D 렌더링에 관심이 많아서 너무 재미있었고, 또 어떤 기능이 존재하는지 알아가는 과정도 굉장히 흥미로웠습니다. 학습 자체도 원래 관심이 많던 분야라 지루함 없이 즐거웠고요.
이번 기회에 알게 된 것들은 정말 빙산의 일각일 것이고, 또 현업에서 다른 3D 모델링 문제를 만난다면 어떻게 해나가야 할지 많이 고민하겠지만, 어찌 됐든 본질에 대한 이해도만 있다면, 요구 사항을 잘 파악하고 제가 사용할 수 있는 기술들로 잘 해결할 수 있을 거라 생각합니다.
긴 글 읽어주셔서 감사합니다.
'Android > Tech' 카테고리의 다른 글
| [Jetpack Compose] Stability Configuration File 은 어떻게 동작하는가 (3) | 2025.03.17 |
|---|---|
| [Jetpack Compose] UI 테스트 작성하기 (0) | 2025.01.15 |
| UiEffect 를 위한 Channel<T> 사용법 (0) | 2025.01.15 |
| 안드로이드의 MVI 와 Reducer (0) | 2025.01.15 |
| [Jetpack Compose] composed {} 와 Modifier.Node 로 Modifier Chain 최적화하기 (0) | 2025.01.15 |