baesw.

BLOG / / / 24 MIN READ

1/8 모형에도 같은 빛이 든다

원래 크기의 방과 아일랜드 위 1:8 모형이 한 번 구운 빛을 나눠 쓰는 이유와 방법. 복사휘도의 스케일 불변성, 정점 조명의 인코딩, 같은 평면만 섞는 잡음 제거, 벽 조명 atlas, 모형 전용 그림자, 단일 톤 매핑과 모바일 메모리 예산까지 「문 너머의 방」의 빛을 따라갑니다.

부엌 아일랜드 위에 놓인 1:8 집 모형. 햇빛이 모형의 벽과 작은 창에 비치고, 옆에는 중정 모형과 초록 등갓이 보인다.

같은 방을 1/8로 줄여도, 같은 빛이 들어야 합니다.

지난 글에서는 「문 너머의 방」의 공간을 꿰매는 수학을 다뤘습니다. 부엌 서쪽 문을 지나면 부엌 아일랜드 위에 놓인 1:8 모형 집 안에 서게 되고, 그 모형의 동쪽 문으로 나가면 원래 크기의 오후의 방으로 돌아옵니다. 문마다 닮음변환 하나를 두고, 걷는 몸과 문 너머를 그리는 카메라가 같은 식을 쓰게 하면 됩니다.

그런데 이 집이 '알고 있던 방'으로 읽히려면 수학만으로는 부족합니다. 모형 안에 들어섰을 때 소파 밑의 그늘, 벽과 천장이 만나는 모서리의 어스름, 창가에서 방 안쪽으로 갈수록 가라앉는 빛이 원래 방과 같아야 합니다. 조금이라도 다르면 관객은 '비슷한 다른 방'에 왔다고 느낍니다.

이번 글은 사진처럼 보이는 실내를 브라우저에서 그리기 위한 일들을 다룹니다.

  1. 무엇을 미리 굽고 무엇을 실시간으로 남기는가 — Cycles 베이크와 실시간 광원의 분업
  2. 1:1의 방과 1:8의 모형이 같은 빛을 쓸 수 있는 이유 — 복사휘도의 스케일 불변성
  3. 정점 하나에 빛 하나 — 고정소수점 인코딩과 이중 계산 막기
  4. 2048개의 경로로는 모자라다 — 같은 평면만 섞는 잡음 제거
  5. 벽에 생긴 각진 얼룩 — 정점 보간의 한계와 벽 조명 atlas
  6. 그림자는 축소되지 않는다 — 1:8 모형만의 햇빛 그림자 지도
  7. 사진 재질과 노출 — 베이크와 화면이 같은 반사율을 쓰게 하기
  8. 7백만 개의 삼각형과 1 GiB의 텍스처 — 전송·메모리·프레임 최적화
오후의 방. 창가 책상과 의자, 소파, 열린 문 너머 눈 쌓인 중정

A · 1:1

같은 구도의 1:8 모형 내부. 열린 문 너머로 원래 크기의 부엌이 보이고 소파 위 벽에 햇빛이 비친다

D · 1:8

FIG. 1왼쪽은 원래 크기의 오후의 방(A), 오른쪽은 부엌 아일랜드 위 모형(D) 안의 같은 자리·같은 방향이다. 소파 뒤 벽, 천장 모서리, 러그의 명암은 같은 간접광 데이터에서 온다. 반면 직사광은 다르다. A에는 서쪽 창의 햇빛이 바닥에 떨어지고, D에는 부엌의 햇빛이 모형의 창으로 들어와 소파 위 벽을 비춘다. 문밖 풍경도 A는 눈 쌓인 중정, D는 원래 크기의 부엌이다.

굽는 빛과 살아 있는 빛

브라우저에는 전역 조명이 없다

방이 사진처럼 보이는 이유의 절반은 직접 비치지 않는 빛, 곧 간접광에 있습니다. 창으로 들어온 햇빛이 마루에 부딪혀 천장을 밝히고, 그 천장이 다시 방 안쪽을 밝힙니다. 이런 다중 반사를 실시간으로 계산하는 일을 전역 조명(GI)이라고 부르는데, WebGL 2와 Three.js에는 그런 기능이 없습니다. 브라우저 GPU에서 경로 추적을 직접 돌리는 방법도 있지만, 세 단계의 포털 재귀 위에서 그것까지 하기에는 프레임 예산이 모자랍니다.

다행히 이 집의 대부분은 움직이지 않습니다. 벽과 가구는 제자리에 있고, 햇빛의 방향도 정해져 있습니다. 움직이는 것은 관객이 돌릴 수 있는 빨간 의자, 켜고 끌 수 있는 작은 등, 내리는 눈 정도입니다. 그래서 작품은 빛을 둘로 나눕니다. 움직이지 않는 빛은 Blender의 경로 추적 렌더러 Cycles로 미리 계산해 정점마다 저장하고, 움직이거나 시점에 따라 달라지는 빛만 브라우저에서 실시간으로 계산합니다.

미리 굽는 빛 (Cycles)실시간으로 남기는 빛 (Three.js)
여섯 번까지 튕기는 확산 간접광해의 직사광과 그 그림자
해를 끈 상태로 창·하늘·천창에서 직접 오는 확산광반사광(정반사): 시점에 따라 달라진다
그 빛이 가구와 모서리에 가려지는 정도작은 등: 관객이 켜고 끈다
방별 HDR 반사 환경

해의 직사광을 굽지 않은 데는 이유가 있습니다. 관객이 의자를 돌리면 그 그림자도 따라 돌아야 합니다. 실시간 그림자 지도는 의자를 돌린 뒤 한 번 다시 그리면 되지만, 구운 빛은 다시 구울 수 없습니다.

반대로 하늘빛을 따로 구운 데도 이유가 있습니다. 처음에는 창 크기의 사각 면광원(RectAreaLight)으로 하늘빛을 흉내 냈습니다. 그런데 Three.js의 면광원은 그림자를 만들지 않습니다. 하늘빛이 소파와 찬장을 그대로 뚫고 지나가서 가구 밑과 뒤가 밝게 떴습니다. 해를 끈 채 창과 하늘에서 직접 오는 빛만 한 번 더 구우면 이런 가림이 데이터에 들어갑니다.

bake-room-indirect.py — 빛을 굽는 설정
scene.render.engine = 'CYCLES'
samples = 4096 if room == 'courtyard' else 2048
scene.cycles.samples = samples
scene.cycles.diffuse_bounces = 6
prefs.compute_device_type = 'OPTIX'
# ...
bpy.ops.object.bake(type='DIFFUSE', pass_filter={'INDIRECT'}, use_clear=True)
# ...
sun.energy = 0
bpy.ops.object.bake(type='DIFFUSE', pass_filter={'DIRECT'}, use_clear=True)

빛을 굽는 장면은 Blender에서 따로 만들지 않습니다. 작품이 브라우저에서 실제로 쓰는 메시를 그대로 내보내 Blender로 가져갑니다(export-room-lighting.mjs). 다섯 장소(오후의 방, 중정, 부엌, 그리고 두 창밖 정원)의 정적 표면 284개가 빛을 받습니다. 실제 모델의 정점마다 2048개의 경로를 추적하고, 중정은 하늘이 넓어 잡음이 많으므로 4096개를 씁니다.

빛은 축소를 모른다

1/8의 방에 다시 구울 필요가 없는 이유

모형 D는 A의 건축과 가구를 그대로 1/8로 줄인 복제입니다. 그렇다면 D의 조명은 따로 구워야 할까요? 작품은 굽지 않습니다. A에 구운 데이터를 D가 그대로 씁니다. 이것이 물리적으로 맞는 이유는 한 점이 받는 빛의 양, 곧 조도의 정의에 있습니다.

점 가 받는 조도 는 사방에서 오는 복사휘도(radiance) 에 입사각의 코사인을 곱해 반구 전체의 입체각으로 적분한 값입니다. 장면 전체를 배로 줄이면 무엇이 바뀔까요?

  • 입체각은 그대로입니다. 창이 배 작아지지만 창까지의 거리도 배 가까워지므로, 바닥의 한 점에서 본 창의 겉보기 크기는 같습니다.
  • 복사휘도도 그대로입니다. 복사휘도는 단위 면적·단위 입체각당 빛의 세기라서 거리와 크기에 무관합니다. 하늘을 1/8 크기의 창으로 봐도 하늘의 밝기는 같습니다.

적분 안의 어떤 항도 길이를 갖지 않으므로 입니다. 튕긴 빛도 마찬가지입니다. 반사된 빛의 복사휘도는 그 표면의 조도와 반사율로 정해지고, 반사율에는 길이가 없습니다. 연기나 안개처럼 빛을 흡수하는 매질이 없다면, 빛의 전달 방정식 전체에 길이의 단위가 들어갈 자리가 없습니다. 그래서 1/8 모형의 같은 자리에는 같은 빛이 듭니다.

● INTERACTIVE축소해도 변하지 않는 빛
창 조도 · 축소 / 원래
×1.000
등 조도 · 축소 / 원래
×64.0
RectAreaLight
세기 그대로 · 폭 ×0.125
PointLight
세기 그대로
FIG. 2오후의 방의 단면. 서쪽 창(면광원)과 플로어 램프(점광원)가 바닥에 만드는 조도를 원래 크기(점선)와 축소한 방(실선)에서 비교한다. 창의 조도는 축척과 상관없이 같은 곡선을 그린다. 점광원은 보정하지 않으면 축척의 제곱에 반비례해 밝아지고, 1:8에서는 64배가 된다. 세기에 s²를 곱하면 다시 겹친다.

점광원만은 예외다

단 하나의 예외가 점광원입니다. 점광원은 크기 없이 한 점에서 빛을 내므로 입체각으로 정의할 수 없고, 대신 광도 로 정의합니다. 점광원이 만드는 조도는 역제곱 법칙을 따릅니다.

장면을 배로 줄이면 이 배가 되어 조도는 배가 됩니다. 1:8 모형이라면 64배입니다. 같은 밝기로 보이려면 광도에 를 곱해야 합니다. 지난 글에서 중정의 하늘에 매달린 거대한 등에 광도를 64배 준 것과 같은 계산을 반대 방향으로 하는 셈입니다.

작품은 모형을 만들 때 이 규칙을 그대로 따릅니다. 점광원은 세기에 를 곱하고, 빛이 닿는 거리 한계에는 를 곱합니다. 면광원은 폭과 높이만 줄이고 세기는 건드리지 않습니다. Three.js의 RectAreaLight의 세기는 복사휘도에 해당하는 값이기 때문입니다.

architecture.ts — 모형 안의 광원
model.traverse(o => {
  if (o instanceof THREE.PointLight) { o.intensity *= MODEL_SCALE * MODEL_SCALE; o.distance *= MODEL_SCALE; }
  if (o instanceof THREE.RectAreaLight) { o.width *= MODEL_SCALE; o.height *= MODEL_SCALE; }
});

같은 빛을 같은 정점에

이론상 같은 빛이라도, 구운 데이터를 모형의 정점에 정확히 대응시키지 못하면 소용이 없습니다. Three.js는 메시마다 무작위 UUID를 붙이므로 그것을 열쇠로 쓸 수 없습니다. 작품은 장면 트리를 정해진 순서로 돌며 빛을 받는 메시마다 afternoon:0, afternoon:1, …처럼 작성 순서로 정해지는 열쇠를 붙입니다. 모형 D는 A를 복제해 만들므로 같은 메시에 같은 열쇠가 붙고, 같은 데이터를 읽습니다.

열쇠만 맞고 형상이 바뀌었다면 더 위험합니다. 가구를 10 cm 옮긴 뒤 예전 베이크를 그대로 쓰면, 옮기기 전 자리의 그늘이 허공에 남습니다. 그래서 정점 위치와 방 안에서의 배치 행렬을 0.1 mm 단위로 반올림해 FNV-1a 해시를 만들고, 내보낼 때와 브라우저에서 불러올 때 비교합니다. 한 메시라도 서명이 다르면 방 전체가 베이크 없이 직접 만든 조명으로 돌아갑니다. 일부만 옛 조명을 쓰면 엉뚱한 곳에서 빛이 새는 것처럼 보이기 때문입니다.

정점 하나에 빛 하나

16비트 고정소수점

구운 빛은 정점마다 RGB 세 값으로 저장합니다. 32비트 부동소수점으로 저장하면 정점 수백만 개에 수십 MB가 듭니다. 작품은 각 채널을 16비트 부호 없는 정수로 저장하고, 값에 4096을 곱해 반올림합니다. 표현 범위는 0 ~ 15.99, 한 단계는 입니다. 실내의 확산광은 대부분 0.01 ~ 1 사이에 있으므로, 어두운 구석에서도 단계가 눈에 띄지 않습니다.

브라우저는 이 정수 배열을 복사하거나 변환하지 않고 그대로 GPU에 올립니다. 정점 속성을 정규화된 정수로 선언하면 GPU가 읽을 때 0 ~ 1로 바꿔 주므로, 셰이더에서 만 곱하면 원래 값이 됩니다.

indirect-light.ts — 구운 빛을 재질에 연결
// vertex shader
vRoomIndirect = roomIndirect * (65535.0 / 4096.0);
// fragment shader, Three.js의 조명 누적 직전
iblIrradiance *= .025;
irradiance = vRoomIndirect * PI;

를 곱하는 이유는 두 렌더러의 약속이 다르기 때문입니다. Cycles의 확산 베이크는 반사율이 1인 표면에서 나가는 복사휘도를 저장합니다. Three.js의 람베르트 BRDF는 조도에 를 곱합니다. 그래서 저장된 값에 를 곱해 조도로 되돌려 넣어야 두 렌더러의 밝기가 맞습니다.

같은 빛을 두 번 세지 않기

구운 빛을 넣을 때 가장 흔한 실수는 같은 빛을 두 번 더하는 것입니다. 베이크에는 이미 창과 하늘과 천창의 확산광이 들어 있는데, 장면에는 창의 반사광을 위해 남겨 둔 실시간 면광원도 있습니다. 그대로 두면 창빛의 확산 성분이 두 번 들어가고, 무엇보다 면광원에는 그림자가 없으므로 구운 그늘을 다시 밝혀 버립니다.

그래서 셰이더에서 세 가지를 조정합니다. 실시간 면광원의 확산 성분은 버리고 정반사만 남깁니다. 촬영 HDR 환경광의 확산 성분은 2.5%만 남기고, 이미 가림이 들어간 베이크 위에 건축 차폐(AO)를 다시 곱하는 비중도 15%로 줄입니다. 그냥 두면 벽과 바닥이 만나는 모서리가 두 번 어두워집니다.

꿰맨 자리의 반점

Three.js는 재질이 같은 메시들을 한 묶음으로 합쳐 그리는데, 이렇게 합친 메시는 삼각형마다 꼭짓점을 따로 갖습니다. 같은 자리의 꼭짓점이 여러 개인 셈입니다. 이 상태로 정점마다 경로를 쏘면, 같은 자리의 꼭짓점들이 서로 다른 잡음을 받아 이음매가 얼룩덜룩해집니다. 그래서 굽기 전에 위치(µm 단위)와 법선이 같은 꼭짓점을 하나로 묶어 한 번만 굽고, 결과를 원래 꼭짓점들에 나눠 줍니다. 법선이 다른 꼭짓점, 곧 각진 모서리는 묶지 않습니다.

bake-room-indirect.py — 굽기 전에 정점 잇기
weld_keys = np.round(np.column_stack((source_points * 1e6, source_normals * 1e5))).astype(np.int64)
_, unique_index, source_to_bake = np.unique(weld_keys, axis=0, return_index=True, return_inverse=True)

2048개의 경로로는 모자라다

몬테카를로 잡음

경로 추적은 확률적인 방법입니다. 정점에서 무작위 방향으로 경로를 쏘아 돌아온 빛을 평균 내므로, 결과에는 늘 잡음이 섞입니다. 잡음의 크기는 경로 수 의 제곱근에 반비례합니다. 잡음을 절반으로 줄이려면 경로가 네 배 필요합니다.

정점이 성길 때는 이 잡음이 잘 보이지 않습니다. 문제는 모델을 정밀하게 다듬으면서 생겼습니다. 문을 홈이 파인 두 장의 패널로 다시 만들고, 넓은 면 가운데에도 25 mm 간격으로 정점을 추가하자, 정점마다 따로 받은 잡음이 그대로 드러났습니다. 문 패널 안쪽 800개 샘플에서 이웃끼리의 차이(잔차 RMS)는 평균 밝기의 약 8.7%였습니다. 경로를 64배 늘리면 1% 남짓으로 줄겠지만, 베이크 시간도 64배가 됩니다.

같은 평면만 섞는다

그래서 작품은 구운 뒤에 잡음을 걸러 냅니다. 가장 쉬운 방법은 이웃 정점과 평균하는 가우시안 필터지만, 그러면 빛과 그늘의 경계도 함께 뭉개집니다. 문틀과 그보다 깊이 파인 패널은 몇 cm 떨어져 있어도 받는 빛이 전혀 다른데, 둘을 섞으면 홈 안쪽의 그늘이 문틀로 번지고 문틀의 빛이 홈으로 샙니다.

작품의 필터는 이웃을 고를 때 세 가지를 확인합니다.

  • 같은 재질인가. 도장 면과 황동 손잡이는 섞지 않습니다.
  • 법선이 거의 같은가. 두 법선의 내적이 0.9995보다 커야 합니다. 약 1.8° 이내입니다.
  • 같은 평면 위에 있는가. 법선 방향으로 0.3 mm 이상 떨어진 이웃은 버립니다.

이 조건을 통과한 이웃만, 반지름 6 cm 안에서 표준편차 25 mm의 가우시안 가중치와 각 정점이 대표하는 면적을 곱해 평균합니다. 면적 가중치가 필요한 이유는 정점 밀도가 고르지 않기 때문입니다. 모서리 근처에 정점이 몰려 있으면, 개수로 평균할 때 모서리의 빛이 평면 전체에 과하게 반영됩니다. 이 평균을 두 번 반복합니다.

bake-room-indirect.py — 이웃 고르기
keep = (vertex_material[ids] == vertex_material[i]) \
     & (normals[ids] @ normals[i] > .9995) \
     & (np.abs(delta @ normals[i]) < .0003)
w = np.exp(-np.sum(delta * delta, axis=1) / (2 * sigma**2)) * np.maximum(area[ids], 1e-12)
● INTERACTIVE구운 빛의 잡음 걸러 내기
재구성
수렴값과의 상대 RMS 오차
8.68 %
평균 밝기 변화
-0.08 %
틀 → 패널 단차 대비
102 %
FIG. 325 mm 간격 정점에 구운 문 패널. 바깥 틀, 비스듬한 홈, 안쪽으로 파인 패널이 서로 다른 평면이다. 평면을 가리지 않는 가우시안은 잡음을 줄이지만 틀과 패널의 단차를 뭉갠다. 같은 평면만 섞으면 단차가 남는다. 하늘빛 패스에 쓰는 밝기 범위 가중치를 더하면 같은 평면 안의 급한 그늘도 덜 번진다. 잡음의 기준은 실제 문에서 2048 경로일 때 잰 8.7%다.

하늘빛 패스에는 조건이 하나 더 붙습니다. 하늘빛은 가구 밑이나 홈 안쪽에서 급격하게 어두워지므로, 같은 평면 안에서도 밝기가 크게 다른 이웃은 덜 섞습니다. 중심 정점 밝기의 25%(최소 0.002)를 폭으로 하는 가중치를 곱하는데, 이렇게 거리와 밝기를 함께 보는 필터를 양방향(bilateral) 필터라고 합니다. 천은 더 조심합니다. 접힌 커튼은 앞면과 뒷면이 0.7 mm 떨어져 있어서 거리로 고르면 반대편 면의 빛이 섞입니다. 그래서 천에서는 메시에서 실제로 연결된 두 단계 이웃만 쓰고, 표준편차도 4 mm로 줄입니다.

결과는 이렇습니다. 문 패널 800개 샘플의 이웃 잔차 RMS는 0.00784에서 0.000267로 약 96.6% 줄었고, 평균 밝기는 0.09058에서 0.09044로 거의 그대로입니다. 필터가 빛을 지우지 않고 잡음만 지웠다는 뜻입니다.

문 패널 표면에 얼룩덜룩한 잡음이 보이는 화면

필터 전

같은 문 패널이 고르게 밝고 홈의 그늘만 남은 화면

같은 평면 재구성 후

FIG. 4부엌 문의 홈 패널. 왼쪽은 필터 전의 구운 빛으로, 정점마다 다른 잡음이 붓질 같은 얼룩을 만든다. 오른쪽은 같은 평면만 섞는 재구성 후다. 문틀과 패널 사이의 그늘은 그대로 남아 있다.

필터를 고칠 때마다 Blender에서 경로를 새로 추적할 필요는 없습니다. 필터 전의 원시 결과를 저장해 두고, 형상·재질·광원 설정의 해시가 같을 때만 그 위에 필터만 다시 적용합니다(--filter-only). 해시가 다르면 저장된 결과를 쓰지 않고 오류로 멈춥니다.

벽에 생긴 각진 얼룩

범인은 보간이었다

잡음을 지운 뒤에도 얼룩이 남았습니다. 액자 아래, 소파 뒤, 창턱 밑의 벽에 삼각형이나 사다리꼴 모양의 어두운 자국이 생겼습니다. 처음에는 실시간 그림자를 의심했지만, 햇빛을 끄고 구운 빛만 남겨도 얼룩은 그대로였습니다.

원인은 정점 간격이었습니다. 벽처럼 넓고 평평한 면은 정점이 약 35 cm 간격으로 놓여 있습니다. 그중 몇몇은 액자나 소파 바로 뒤에 숨어 있어서 빛을 거의 받지 못합니다. GPU는 정점 사이의 값을 삼각형 안에서 직선으로 보간하므로, 숨은 정점의 어두운 값이 이웃한 삼각형 전체로 퍼집니다. 실제 그늘은 액자 테두리에서 몇 cm만 지는데, 화면에는 삼각형 크기만 한 얼룩이 생깁니다. 정점 조명은 넓은 면의 빛을 정점 간격보다 세밀하게 담을 수 없습니다.

● INTERACTIVE정점 조명과 벽 조명 atlas
저장 방식
벽 정점 수
108
5 % 넘게 틀린 벽 면적
18.2 %
가장 큰 상대 오차
67 %
FIG. 5액자와 소파가 있는 3.6 × 2.7 m 벽. 정점 조명에서는 액자와 소파 뒤에 숨은 정점(큰 점)의 어두운 값이 삼각형을 따라 번진다. 간격을 줄이면 얼룩도 작아지지만 사라지지는 않는다. 벽 조명 atlas는 약 1.4 cm 텍셀에 빛을 저장하고 텍셀 사이를 쌍선형으로 읽으므로, 그늘이 실제 틈 주변에만 남는다.

벽만 텍스처로

정점을 촘촘하게 늘리면 얼룩은 작아지지만, 정점 수가 제곱으로 늘고 같은 잡음 문제가 다시 생깁니다. 작품은 벽만 다른 방식으로 저장합니다. 오후의 방과 부엌의 네 실내 벽을 2×2 칸으로 펼친 1024×512 조명 텍스처(atlas) 에 굽습니다.

  • 원래 벽 삼각형을 그대로 받는 면으로 쓰고, 가구와 액자와 창턱은 원래 형상대로 빛을 가립니다. 겹치는 면을 새로 만들지 않습니다.
  • 텍셀마다 2048개의 경로로 간접광과 해를 끈 창빛을 굽고, 선형 HDR 상태에서 Intel Open Image Denoise(OIDN)로 잡음을 지웁니다.
  • 마지막에 텍셀 하나 반경의 작은 필터를 한 번 더 겁니다. 이 필터는 빈 텍셀(창이나 문 구멍)을 가중치 0으로 건너뛰고, atlas의 칸 경계를 넘지 않습니다.
  • 반정밀도(float16)로 저장하기 전에 값을 정점 조명과 같은 1/4096 단계로 반올림합니다. 그 이하의 미세한 몬테카를로 잡음은 눈에 보이지 않으면서 압축만 방해하기 때문입니다.

브라우저에서는 벽의 위치와 법선으로 atlas 좌표를 계산합니다. 벽의 정점이 방의 네 경계 평면 중 하나 위에 있고 법선이 방 안쪽을 향하면, 그 평면 위 좌표를 atlas의 해당 칸으로 바꿉니다. 벽의 바깥면, 옆면, 비어 있는 텍셀은 원래의 정점 조명을 씁니다.

indirect-light.ts — 벽 정점에서 atlas 좌표 구하기
if (normal.z > .99 && abs(position.z - wb.y) < .002) { face = 0.; wallUV = vec2((position.x - wb.x) / wb.z, position.y / roomWallLayout.w); }
// ... 나머지 세 벽
vec2 cell = vec2(mod(max(face, 0.), 2.), floor(max(face, 0.) / 2.)), size = roomWallLayout.xy * .5;
vRoomWallUV = vec3((cell * size + roomWallLayout.z + wallUV * (size - 2. * roomWallLayout.z)) / roomWallLayout.xy, float(face >= 0.));

atlas 데이터는 기존 조명 파일 뒤에 그대로 덧붙입니다. 오후의 방 조명 파일의 앞부분 8,299,536바이트는 이전 파일과 바이트 단위로 같고, 그 뒤에 RGBA float16 atlas가 붙습니다. 그래서 추가 요청이 없고, 1:1 방과 1:8 모형이 같은 atlas를 나눠 씁니다. GPU 텍스처는 8 MiB가 늘고, Brotli로 압축한 전송량은 약 2.43 MiB가 늡니다. 같은 시점에서 atlas를 켜고 끈 GPU 시간 중앙값은 3.76 ms와 3.83 ms로 측정 변동 범위 안이었습니다.

액자 주변 벽에 비스듬한 사각형 모양의 어두운 얼룩이 있는 화면

정점 조명

같은 벽에서 얼룩이 사라지고 액자 아래에 좁은 그늘만 남은 화면

벽 조명 atlas

FIG. 6오후의 방 소파 벽. 왼쪽은 정점 조명으로, 액자 왼쪽 아래와 오른쪽 위로 각진 그늘이 번져 있다. 오른쪽은 벽 조명 atlas로, 그늘이 액자 테두리 가까이에만 남는다.

스칼라 빛과 미세한 요철

구운 빛에는 방향 정보가 없습니다. 정점마다 '얼마나 밝은가'만 저장하므로, 1 ~ 3 mm 크기의 붓결이나 종이 섬유 같은 노멀 맵의 요철에 반응하지 않습니다. 가까이 다가가면 도장 면이 평평한 사진처럼 보입니다.

방향별로 빛을 다시 굽는 대신, 작품은 이 방의 주 광원인 서쪽 창 방향으로 1차 근사를 둡니다. 노멀 맵이 기울인 법선과 원래 법선이 그 방향을 얼마나 향하는지의 비를 구해 구운 빛에 곱하고, 그 비는 ±12% 안으로 자릅니다. 노멀 맵이 없는 곳에서는 비가 정확히 1이므로 구운 빛이 바뀌지 않습니다.

그림자는 축소되지 않는다

1:8 모형의 계단 무늬

빛은 축소를 모르지만 그림자 지도는 다릅니다. 실시간 햇빛 그림자는 해 쪽에서 내려다본 깊이 텍스처로 계산하는데, 이 텍스처의 텍셀 하나가 덮는 실제 길이는 정해져 있습니다. 부엌의 햇빛 그림자 지도는 부엌 전체를 덮어야 하므로, 아일랜드 위의 1:8 모형에서는 텍셀이 모형 크기에 비해 8배 거칠게 떨어집니다.

그림자 지도 (데스크톱)덮는 폭 / 해상도텍셀 하나원래 방 크기로 환산
A의 햇빛22 m / 40965.4 mm5.4 mm
부엌의 햇빛이 모형에 떨어질 때15.6 m / 40963.8 mm30 mm
모형 전용 햇빛1.58 m / 20480.77 mm6.2 mm

관객은 모형 안에서 1/8 크기의 눈으로 보므로, 모형에 떨어진 3.8 mm 텍셀은 원래 방의 3 cm 텍셀처럼 보입니다. 모형 벽의 햇빛 자국이 모자이크처럼 깨진 이유입니다.

모형 안 벽의 햇빛 자국이 사각형 모자이크처럼 깨진 화면

부엌 그림자 지도

같은 벽에 창틀 모양의 햇빛이 매끄럽게 떨어진 화면

모형 전용 그림자 지도

FIG. 71:8 모형 안의 같은 벽. 왼쪽은 부엌의 그림자 지도를 그대로 쓴 화면으로, 햇빛 자국이 3 cm 크기의 계단으로 깨진다. 오른쪽은 모형 전용 그림자 지도를 쓴 화면으로, 창틀의 그림자까지 읽힌다.

해를 세 개 두되, 한 번씩만 비추기

해결책은 모형마다 그림자 지도를 따로 두는 것입니다. 집 모형과 중정 모형 위에 부엌의 해와 방향·색·세기가 같은 방향광을 하나씩 더 두고, 각자의 경계 상자에 꼭 맞는 그림자 카메라를 씌웁니다. 부엌의 다른 물체도 그대로 그림자를 드리우므로, 찬장이나 등갓의 그림자가 모형에 떨어지는 것도 유지됩니다.

문제는 이제 부엌에 해가 세 개라는 점입니다. 그대로 두면 모든 표면이 세 배 밝아집니다. 작품은 재질마다 자기가 속한 지도 번호(부엌 0, 집 모형 1, 중정 모형 2)를 uniform으로 주고, 방향광 반복문에서 자기 번호의 해만 계산하게 합니다. 다른 해는 색을 0으로 만들고 BRDF 계산도 건너뜁니다.

shading.ts — 자기 지도의 해만 비추기
getDirectionalLightInfo( directionalLight, directLight );
directLight.visible = abs(float(UNROLLED_LOOP_INDEX) - roomSunCascade) < .25;
directLight.color *= float(directLight.visible);

그림자 지도의 깊이 비교에 쓰는 바이어스도 크기를 따라가야 합니다. 바이어스는 표면이 자기 자신에게 그림자를 드리우는 얼룩을 막기 위해 비교 위치를 법선 방향으로 조금 밀어내는 값인데, 원래 방에 맞춘 몇 mm를 모형에 그대로 쓰면 모형에서는 몇 cm를 미는 셈이 되어 그림자가 물체에서 떨어져 뜹니다. 셰이더는 모델 행렬의 배율(0.125 ~ 1)을 바이어스에 곱합니다.

바닥에 닿는 그늘

가구의 무게감은 바닥에 닿는 곳의 짙고 좁은 그늘에서 옵니다. 햇빛 그림자 지도로는 이 몇 mm의 그늘을 잡기 어렵고, 화면 공간 AO(SSAO)는 포털 영상마다 다시 계산해야 합니다. 작품은 방에 들어가는 순간 바닥, 러그, 조리대, 테이블, 선반 등 13개의 받침 면에서 위를 올려다본 높이 지도를 한 번 찍어 둡니다. 그 높이 지도로 2.5 mm 반경의 밀착 그늘과 14 ~ 22 mm 반경의 넓은 그늘을 두 채널에 나눠 저장하고, 받는 면의 간접광에만 곱합니다. A와 모형 D는 방 좌표가 같으므로 이 지도도 나눠 씁니다. 회전하는 의자는 높이 지도에서 빼서, 돌린 뒤에 예전 자세의 그늘이 남지 않게 합니다.

사진 재질과 노출

사진 재질을 길들이기

실내의 나무, 회벽, 돌, 천, 가죽에는 Poly Haven과 ambientCG의 CC0 사진 기반 PBR 재질을 씁니다. 그런데 사진 재질을 받은 그대로 붙이면 사진 같아 보이지 않습니다. 예를 들어 처음 쓴 나무 재질은 16,384개 지점에서 잰 평균 반사율이 선형 RGB로 (0.055, 0.030, 0.016)이었습니다. 실제 마루보다 훨씬 어두운 값이라, 반복되는 흠집이 화면에서 검은 얼룩처럼 보였습니다.

그래서 재질마다 측정한 평균을 중심으로 대비를 낮추고 이득을 곱하는 응답을 셰이더에 둡니다. 회벽은 평균 선형 RGB (0.435, 0.392, 0.334)를 중심으로 대비를 벽 45%, 천장 28%로 낮추고, 노멀 강도도 벽 0.065, 천장 0.025로 줄였습니다. 원본 파일은 고치지 않습니다. 같은 사진을 여러 장소에서 다른 응답으로 쓸 수 있고, 출처의 해시도 그대로 검증됩니다.

중요한 것은 이 응답을 베이크에도 똑같이 적용하는 일입니다. 튕긴 빛의 색은 표면의 반사율에서 나옵니다. 브라우저에서는 대비를 낮춘 마루를 보여 주면서 Blender에서는 원본 마루로 빛을 튕기면, 천장에 비치는 마루빛의 색과 세기가 화면 속 마루와 어긋납니다. 그래서 Blender 장면의 재질 노드도 같은 채도, 대비, 이득을 그대로 재현합니다.

bake-room-indirect.py — 화면과 같은 반사율로 빛을 튕기기
contrast = mat.node_tree.nodes.new('ShaderNodeMixRGB')
contrast.blend_type = 'MIX'; contrast.inputs[0].default_value = response['contrast']
contrast.inputs[1].default_value = [*response['pivot'], 1]
gain = mat.node_tree.nodes.new('ShaderNodeMixRGB')
gain.blend_type = 'MULTIPLY'; gain.inputs[2].default_value = [response['gain']] * 3 + [1]

노출은 한 번만

마지막 단계는 HDR 값을 화면 색으로 바꾸는 톤 매핑입니다. 포털이 있는 장면에서는 이 단계를 언제 하느냐가 중요합니다.

톤 매핑은 비선형 함수입니다. 문 너머의 영상을 먼저 톤 매핑해 텍스처로 만들고, 그것을 관객 쪽 장면과 합친 뒤 다시 톤 매핑하면, 문 너머만 두 번 압축되어 탁해집니다. 반대로 문 너머를 톤 매핑한 채 두고 관객 쪽만 톤 매핑하면, 반투명한 눈 입자처럼 두 화면에 걸쳐 섞이는 것의 밝기가 문틀을 경계로 달라집니다. 실제로 초기 버전에서는 직접 본 눈과 문 너머로 본 눈의 밝기가 달랐습니다.

작품은 모든 단계를 선형 HDR로 합니다. 관객 장면과 모든 포털 영상을 RGBA16F 렌더 타깃에 선형 그대로 그리고 합성합니다. 투명한 눈과 접촉 그늘까지 다 합친 뒤에 하이라이트 확산(가중치 0.085)과 FXAA를 선형 공간에서 처리하고, 맨 마지막에 단 한 번 AgX 톤 매핑(노출 1.38)과 sRGB 변환을 합니다. 노출은 모든 방에서 같습니다. 자동 노출을 쓰면 밝은 중정과 어두운 부엌이 한 화면의 문틀 안팎에 동시에 보일 때 어느 노출을 따라야 할지 정할 수 없기 때문입니다.

7백만 개의 삼각형과 1 GiB의 텍스처

사진 같은 실내는 무겁습니다. 자산 묶음 43개, 수백 장의 사진 텍스처, 다섯 장소의 구운 빛을 한 페이지에 싣고, 그것을 한 프레임에 여러 시점에서 그려야 합니다. 작품은 모델을 단순화하거나 빛을 다시 굽지 않고, 전송·메모리·프레임 비용을 각각 따로 줄였습니다.

파일 크기와 GPU 크기는 다르다

압축 이미지의 파일 크기는 GPU가 쓰는 메모리와 상관이 없습니다. 브라우저는 WebP를 풀어 RGBA8로 올리고 밉맵을 만듭니다. 밉맵 전체는 원본의 1/3을 더하므로, 한 장의 GPU 크기는 대략 이렇습니다.

2048² 텍스처 한 장은 파일로 수백 KB여도 GPU에서는 약 21 MiB입니다. 작품의 모바일 경로에는 2K 텍스처가 많이 남아 있었고, 이 식으로 계산한 이미지 예산만 1,011 MiB였습니다. 실제 휴대폰에서 탭이 종료될 만한 크기입니다. 이 문제는 모바일 전용 파생 묶음을 따로 만들어 해결했습니다. 형상, UV, 실제 타일 크기, 배치는 원본과 공유하고 이미지 해상도만 정해진 상한으로 줄입니다. 색상은 512px(소파와 의자 같은 큰 가구는 1024px), 노멀과 ARM 지도는 256px이 상한입니다. 색은 WebP 품질 90, 데이터 지도는 무손실로 저장합니다. HDR 환경 지도는 선형 광량을 면적 평균해 512×256으로 줄였는데, 평균 휘도 오차는 0.081%와 0.112%였습니다.

모바일 경로이전이후
이미지 RGBA8 + 밉맵 (계산값)1,011.27 MiB118.97 MiB
구운 빛을 붙인 뒤 남는 ArrayBuffer (Node 측정)796.48 MiB216.80 MiB
셰이더 프로그램215120

정점 배열은 공유로 줄였습니다. 구운 빛을 붙일 때 원래 메시의 위치·법선·UV 속성을 복사하지 않고 빌려 오는 얇은 껍데기 geometry를 만들고, 조명 속성만 새로 붙입니다. 모바일에서는 법선과 차폐값을 정규화 정수로, UV와 색과 접촉 좌표를 half float로 줄입니다. 위치와 mm 단위의 손잡이 좌표는 정밀도가 필요하므로 float32로 둡니다. 첫 GPU 업로드가 끝나면 더 이상 쓰지 않는 중간 geometry와 모델 원형을 바로 해제합니다.

요청을 줄이고, 바이트를 줄이기

처음에는 모델 30개가 각각 glTF JSON, 메시 버퍼, 이미지 110장을 따로 요청했습니다. JSON을 받아야 이미지 요청을 시작할 수 있으니 요청이 두 단계로 직렬화되었습니다. 지금은 모델마다 원본 바이트를 재인코딩 없이 GLB 하나에 담아, 모델 관련 요청이 170개에서 30개로 줄었습니다.

메시 버퍼와 조명 필드는 Brotli(품질 9)로 압축해 내용 해시가 들어간 주소에 올립니다. 해당 경로에만 Content-Encoding: br을 붙이면 브라우저가 알아서 풀어 주므로, 런타임 디코더나 새 패키지가 필요 없습니다. 전송량은 67.93 MiB에서 23.41 MiB로 65.5% 줄었고, 압축을 푼 결과는 원본과 SHA-256까지 같습니다. 주소에 해시가 들어 있으니 immutable 캐시를 걸 수 있고, 조명 JSON과 바이너리도 동시에 요청합니다.

한 프레임에 7백만 개

부엌의 기본 시점은 실제 부엌, 집 모형, 중정 모형, 그리고 그 문 너머까지 10개 렌더 패스에서 삼각형 7,246,633개를 제출했습니다. 지난 글에서 다룬 조상 사각형 컬링에 더해, 세 가지를 더 했습니다.

  • 공간 타일. 벽과 바닥, 눈처럼 큰 메시를 공간별 삼각형 인덱스로 미리 나눠 둡니다. 정점 데이터는 원본을 공유하고 인덱스만 따로 둡니다. 보이는 타일만 제출하되, 실제로 정점을 절반 이상 건너뛸 수 있을 때만 쪼개 그립니다. 드로우콜이 늘어나는 비용이 더 클 수 있기 때문입니다.
  • scissor로 지우기. 포털 영상은 실제로 샘플링될 사각형(두 텍셀 여유 포함)만 지우고 그립니다. 같은 시점에서 셰이딩한 픽셀이 3,356,928개에서 1,479,779개로 줄었습니다.
  • 오차가 0.5픽셀 미만일 때만 쓰는 커튼. 중정의 작은 배경 창에 걸린 커튼 여섯 장이 실내 커튼과 같은 밀도로 374,784개의 삼각형을 쓰고 있었습니다. 주름을 가로지르는 96개 구간과 두께, 접힌 밑단은 그대로 두고, 거의 직선인 세로 구간만 줄인 표시용 인덱스를 만들었습니다. 원본과의 위치 차이는 약 0.3 mm입니다. 이 차이를 실제 렌더 높이와 카메라 깊이로 화면에 투영해 0.5픽셀보다 작을 때만 표시용 면을 쓰고, 가까이 가면 원본으로 돌아옵니다.
부엌 기본 시점 (1600×900 버퍼)이전이후
제출 삼각형7,246,6334,590,850
드로우콜532423
GPU 중앙값7.20 ms5.99 ms
CPU 제출 중앙값5.8 ms4.8 ms

변경 전후 같은 시점의 화면 차이는 RGB 평균 절대 차이로 약 0.012/255였습니다. 계속 내리는 눈 입자의 시간을 맞추지 않은 채 잰 값이므로, 픽셀이 완전히 같다는 뜻은 아닙니다. 겨울 하늘을 불투명한 건물 뒤에 그리도록 순서를 바꿔, 깊이 검사로 가려진 구름 계산을 건너뛰게 한 것도 작은 이득입니다.

모바일의 몫

모바일은 화면 48만 픽셀, 30fps, 1K 그림자 지도를 목표로 합니다. 재귀 3단계와 공간 수학은 데스크톱과 똑같이 두고, 그림자 샘플 수, 렌더 타깃 해상도, MSAA를 줄입니다. 가장 큰 차이는 실내 반사 probe입니다. 데스크톱은 방에 들어갈 때 방 전체를 큐브 맵 6면으로 다시 그려 유약과 황동에 비치는 실내 반사를 만듭니다. 모바일은 이 과정을 빼고 촬영 HDR 반사만 씁니다. 검증 PC의 모바일 분기에서 이 구간이 포함된 준비 시간은 37.88초, 뺀 뒤에는 6.17초였습니다.

얼마나 맞는가

검증 항목결과
정적 간접광 수신 형상의 서명 (실물·모형 공유 포함)284 / 284 일치
문 패널 800개 샘플의 이웃 잔차 RMS0.00784 → 0.000267 (평균 0.09058 → 0.09044)
벽 atlas 켬/끔의 같은 시점삼각형 1,493,122개·드로우콜 199개 동일
창밖 담장의 수평 시선 492개틈 0
자동 검사 (HDR 유효성, 잘못된 atlas의 대체, 자원 해제 포함)240 / 240 통과
스트림 원본 체크섬 (prepare-room-stream.mjs --check)통과

마치며

1:8 모형에 같은 빛이 드는 이유는 물리에서 나옵니다. 조도는 복사휘도를 입체각으로 적분한 값이고, 둘 다 길이를 갖지 않습니다. 그래서 작품은 1:1 방의 빛을 한 번 굽고 1:8 모형이 그대로 나눠 씁니다. 거리를 따르는 것은 점광원 하나뿐이라 세기에 를 곱합니다.

대신 축소되지 않는 것들은 하나씩 손봐야 했습니다. 정점 간격은 그대로라 넓은 벽에는 텍스처를 따로 썼습니다. 그림자 지도의 텍셀도 그대로라 모형 전용 해를 따로 두었고, 바이어스는 배율을 따라 줄였습니다. 몬테카를로 잡음은 같은 평면만 섞어 지웠습니다. 사진 재질은 브라우저와 Blender가 같은 반사율을 쓰도록 길들였고, 노출은 모든 포털을 합친 뒤에 한 번만 정했습니다.

이 모든 장치가 관객에게 보이지 않아야 작품이 성립합니다. 작품에서 부엌의 서쪽 문을 지나 모형 안으로 들어가 보세요. 소파 뒤의 그늘과 천장 모서리의 어스름이 처음 방과 같다면, 그 방이 1/8 크기라는 것은 창밖의 거대한 부엌을 보고서야 알게 됩니다.

참고 자료

  • J. T. Kajiya, "The Rendering Equation" (SIGGRAPH 1986) — 빛 전달 방정식. 길이 단위가 들어가지 않는 구조가 이 글의 스케일 불변성 논의의 바탕이다.
  • J. H. Lambert, Photometria (1760); J. Arvo, "Applications of Irradiance Tensors to the Simulation of Non-Lambertian Phenomena" (SIGGRAPH 1995) — 다각형 면광원의 조도 공식. FIG. 2의 계산에 쓴 식.
  • E. Heitz 외, "Real-Time Polygonal-Light Shading with Linearly Transformed Cosines" (SIGGRAPH 2016) — Three.js RectAreaLight가 쓰는 면광원 셰이딩.
  • C. Tomasi, R. Manduchi, "Bilateral Filtering for Gray and Color Images" (ICCV 1998) — 하늘빛 패스의 밝기 범위 가중치.
  • Intel Open Image Denoise, Blender Cycles 문서 — 벽 조명 atlas의 HDR 잡음 제거와 베이크 패스.
  • R. Fernando, "Percentage-Closer Soft Shadows" (SIGGRAPH 2005 Sketches); Three.js r181 webgl_shadowmap_pcss, webgl_shadow_contact 예제 — 부드러운 햇빛 그림자와 접촉 그늘.
  • Poly Haven, ambientCG — 작품에 쓴 CC0 모델·사진 재질·HDR. 출처와 해시는 작품의 자산 기록에 있다.