BLOG / / 24 MIN READ
2⁵¹²배 안쪽으로
float32로는 천 배도 버티지 못하는 화면을 10¹⁵⁴배까지 확대하기. 섭동 이론, 직접 만든 지수, 리베이스, 급수 근사, 반발 주기점을 따라 「Further within」을 처음부터 다시 만들어 봅니다.

끝은 없고, 안쪽만 있습니다.
Mandelbrot 집합을 확대하는 영상을 한 번쯤 보셨을 겁니다. 소용돌이 안에 소용돌이가 있고, 그 가지 끝에 처음 모양을 닮은 작은 섬이 떠 있고, 그 섬을 확대하면 다시 소용돌이가 나옵니다. 이 그림은 라는 한 줄짜리 규칙에서 나옵니다. 그래서 프랙탈 확대는 "간단한 규칙에서 끝없는 복잡함이 나온다"는 말의 단골 예시가 됩니다.
그런데 이 영상을 직접 만들어 보면 금방 벽에 부딪힙니다. GPU 셰이더에 그 한 줄을 그대로 옮기면, 천 배쯤 확대한 지점에서 화면이 모자이크로 무너집니다. 수학에는 끝이 없지만 컴퓨터의 숫자에는 끝이 있기 때문입니다.
플레이그라운드의 「Further within」은 Mandelbrot 집합을 512옥타브, 곧 배까지 브라우저에서 실시간으로 확대합니다. 관측 가능한 우주의 지름을 플랑크 길이까지 줄이는 비율이 약 이니, 그 확대를 두 번 반 되풀이하는 셈입니다. 이 글에서는 "끝없이 안쪽으로"라는 한 문장을 지키기 위해 작품이 쌓아 올린 수치해석 기법을 하나씩 따라갑니다.
- 왜 float로는 천 배를 못 버티는가 — 정밀도의 벽
- 한 점만 정확하게, 나머지는 차이로 — 섭동 이론
- 지수가 모자랄 때 — 직접 만든 부동소수점
- 기준이 먼저 끝날 때 — 리베이스
- 처음 수천 번을 한 번에 — 급수 근사
- 어디로 내려갈 것인가 — 경로 정찰
- 이음매 없이 영원히 — 반발 주기점과 Julia 루프
- 반복 횟수가 표면이 되기까지 — 색과 빛
이 글의 그림은 대부분 직접 만져 볼 수 있습니다. 작품의 기준 궤도 코드와 경로 탐색 코드를 Worker에서 그대로 돌리고, 셰이더의 반복 루프는 CPU로 한 줄씩 옮겨 이 페이지에서 실행합니다.
한 줄짜리 규칙
복소수 를 하나 고르고, 에서 출발해 다음 식을 되풀이합니다.
어떤 에서는 가 원점 근처를 영원히 맴돌고, 어떤 에서는 언젠가 무한대로 달아납니다. 영원히 갇히는 의 모임이 Mandelbrot 집합입니다. 한 번이라도 가 되면 그 뒤로는 반드시 발산한다는 것이 증명되어 있어서, 계산할 때는 "반지름 2의 원을 벗어날 때까지 몇 번 걸렸는가"를 세면 됩니다. 이것을 탈출 시간(escape time) 이라고 부르고, 화면의 색은 대부분 이 횟수에서 나옵니다.
경계 근처를 움직여 보면 이 집합이 왜 끝없이 복잡한지 감이 옵니다. 경계에 가까운 는 오래 버티다가 탈출하고, 그 바로 옆의 는 전혀 다른 시점에 탈출합니다. 입력의 아주 작은 차이가 반복을 거치며 크게 벌어지는 것, 곧 혼돈입니다. 이 성질이 무늬를 끝없이 만들어 내는 원천이고, 동시에 계산을 어렵게 만드는 원인이기도 합니다.
정밀도의 벽
좌표가 먼저 사라진다
GPU 셰이더의 기본 수는 32비트 부동소수점(float32)입니다. 유효숫자가 24비트, 십진수로 일곱 자리 남짓입니다. 작품이 내려가는 곳 근처의 에서는 float32가 표현할 수 있는 이웃한 두 수의 간격(ulp)이 입니다.
화면 세로가 1,000픽셀이고 처음에 복소평면의 높이 3.2를 보여 준다면, 옥타브 확대했을 때 픽셀 하나의 간격은 입니다. 이것이 보다 작아지는 부터는 이웃한 픽셀들이 같은 좌표를 받습니다. 같은 좌표에서 같은 계산을 하니 같은 색이 나오고, 화면은 블록으로 뭉갭니다. float64로 바꿔도 유효숫자 53비트로 에서 같은 일이 생깁니다.
그보다 먼저 혼돈이 무너뜨린다
실제로는 그보다 훨씬 먼저 무너집니다. 반복 한 번마다 에 생긴 오차는 대략 배로 불어납니다. 경계 근처의 궤도는 수백, 수천 번을 돌기 때문에, 처음에는 마지막 자리에 있던 반올림 오차가 결국 앞자리까지 올라옵니다. 그러면 탈출 횟수가 달라지고, 결국 엉뚱한 색이 칠해집니다.
작품의 경로를 따라 160×90 격자를 세 방법으로 계산하고, 탈출 횟수가 섭동 결과와 2회 이상 다른 픽셀의 비율을 세어 보면 다음과 같습니다.
| 깊이 (옥타브) | 배율 | float32가 틀린 픽셀 | float64가 틀린 픽셀 |
|---|---|---|---|
| 8 | 256 | 4.5% | 0% |
| 12 | 4,096 | 23.8% | 0.1% |
| 16 | 6.6×10⁴ | 41.9% | 0.2% |
| 32 | 4.3×10⁹ | 99.3% | 5.0% |
| 44 | 1.8×10¹³ | 100% | 30.9% |
| 56 | 7.2×10¹⁶ | 100% | 93.8% |
float32는 열 옥타브 남짓, 배율 천 배 근처에서 이미 화면의 4분의 1이 틀립니다. float64도 마흔 옥타브를 넘기지 못합니다. 512옥타브까지 가려면 수의 길이를 늘리는 것과는 다른 생각이 필요합니다.
섭동 이론 — 한 점만 정확하게
차이만 계산하기
임의 정밀도 수(BigInt)로 모든 픽셀을 계산하면 정확하긴 하지만, 수백만 픽셀에 수천 번의 반복을 640비트 곱셈으로 하면 한 프레임에 몇 분이 걸립니다. 2013년 K. I. Martin이 SuperFractalThing에서 널리 알린 섭동(perturbation) 기법은 이 문제를 이렇게 피합니다. 화면 안의 한 점만 정확하게 계산하고, 나머지 픽셀은 그 점과의 차이만 계산한다.
기준점 의 궤도를 , 어떤 픽셀의 와 그 궤도를 이라 합시다. 두 점화식을 빼면 다음과 같습니다.
마법은 이 식에 큰 수에서 큰 수를 빼는 항이 없다는 데 있습니다. 부동소수점이 정밀도를 잃는 전형적인 경우는 거의 같은 두 수를 뺄 때(상쇄)입니다. 를 직접 다루면 과 의 차이를 맨 마지막 자리에서 구해야 하지만, 와 을 처음부터 따로 들고 다니면 그 작은 값 자체의 유효숫자 24비트를 온전히 쓸 수 있습니다. 화면의 무늬를 결정하는 것은 픽셀 사이의 차이이고, 그 차이만큼은 float로도 충분히 정확합니다.
기준 궤도는 BigInt로
기준 궤도는 Worker 안에서 JavaScript의 BigInt로 계산합니다. 실수 를 정수 로 저장하는 고정소수점이고, 곱셈 뒤에 비트만큼 오른쪽으로 밀면 됩니다. 정밀도는 깊이에 128비트의 여유를 더한 입니다.
const bits = Math.max(160, Math.ceil(depth) + 128);
const mul = (a: bigint, b: bigint) => (a * b) >> BigInt(bits);
for (let j = 0; j <= iterations; j++) {
const fx = number(x), fy = number(y);
const hx = Math.fround(fx), hy = Math.fround(fy);
orbit.set([hx, hy, fx - hx, fy - hy], j * 4); // float32 두 개: hi + lo
if (fx * fx + fy * fy > 65536) break;
[x, y] = [mul(x, x) - mul(y, y) + cr, 2n * mul(x, y) + ci];
}궤도의 각 점은 GPU에 넘기기 전에 float32 두 개로 쪼갭니다. hx는 float32로 반올림한 값, fx - hx는 그때 버려진 나머지입니다. 이 둘을 RGBA 텍스처 한 줄에 담으면 셰이더는 유효숫자 약 48비트의 기준 궤도를 읽을 수 있습니다. 이 '두 float로 한 수 만들기'를 double-single 산술이라고 부르며, 뒤에서 다시 나옵니다.
작품의 경로 위에서 실제로 재 보면 BigInt 계산은 생각보다 쌉니다.
| 깊이 | 정밀도 | 기준 궤도 길이 | 계산 시간 (Node, 데스크톱) |
|---|---|---|---|
| 64 | 192비트 | 1,185회 | 11 ms |
| 128 | 256비트 | 1,569회 | 15 ms |
| 256 | 384비트 | 2,337회 | 22 ms |
| 512 | 640비트 | 3,862회 | 35 ms |
픽셀 하나의 궤도를 이만큼 정밀하게 계산하는 데 수십 ms가 걸립니다. 4K 화면의 830만 픽셀 전부를 이렇게 계산했다면 한 프레임에 사흘이 넘게 걸렸을 겁니다.
지수가 모자랄 때
2⁻¹²⁶ 아래의 세계
섭동만으로 끝나면 좋겠지만, 512옥타브에는 문제가 하나 더 있습니다. 깊이 에서 픽셀 사이의 는 대략 입니다. 라면 입니다. 그런데 float32의 지수는 8비트라서 보다 작은 수는 표현하지 못하고 0이 됩니다(비정규 수를 써도 까지). float64라면 까지 버티지만, WebGL의 셰이더에는 float64가 없습니다.
유효숫자는 충분한데 지수가 모자란 상황입니다. 그래서 작품은 지수를 따로 들고 다닙니다. 를 가수 와 정수 지수 로 나누어 다음처럼 표현합니다.
는 double-single 복소수이고, 는 float 변수 하나에 담은 정수입니다. 가 65,536을 넘으면 를 로 나누고 에 32를 더합니다. 이렇게 하면 는 늘 적당한 크기에 머물고, 지수의 범위는 사실상 무제한이 됩니다.
셰이더 안의 점화식
이 표현으로 섭동 점화식을 다시 쓰면, 양변을 로 나눈 식이 됩니다.
작품의 프래그먼트 셰이더는 이 식을 거의 그대로 옮깁니다. dc는 화면 단위의 픽셀 위치이고 실제 는 dc이므로, 마지막 항은 dc * power(-exponent)가 됩니다.
float power(float p) { return p < -126. ? 0. : exp2(min(p, 120.)); }
vec4 ref = reference(index); // Zₙ (hi, lo)
vec4 delta = q * power(exponent - uDepth); // δₙ = q · 2^(e−D)
q = addC(addC(2. * mulC(ref, q), mulC(q, delta)), dc * power(-exponent));
if (max(abs(q.x), abs(q.y)) > 65536.) { q *= exp2(-32.); exponent += 32.; }
index++;
delta = q * power(exponent - uDepth);
vec4 z = addC(reference(index), delta); // zₙ₊₁ = Zₙ₊₁ + δₙ₊₁
r2 = dot(z.xy, z.xy);
if (r2 > 65536.) break; // 탈출power()가 보다 작은 값을 0으로 돌려주는 것은 버그가 아닙니다. 이 그만큼 작을 때 항은 에 비해 대략 배이므로, 버려도 결과가 달라지지 않습니다. 가 자라서 위로 올라오면 그때부터 비선형 항이 살아납니다.
아래 그래프는 한 픽셀의 이 반복할수록 어떻게 자라는지 보여 줍니다. 초록 실선은 지수 범위가 넓은 float64로 계산한 기준값이고, 노란 점선은 작품의 방식(와 )으로 계산한 값입니다. 둘이 겹친다는 것은 float32 두 개와 정수 지수 하나로 float64 이상의 범위를 흉내 냈다는 뜻입니다.
기준이 먼저 끝날 때
글리치
섭동에는 오래된 약점이 있습니다. 픽셀은 기준 궤도를 '따라가면서' 계산되는데, 기준 궤도가 먼저 탈출해 버리면 따라갈 이 없어집니다. 기준점이 300번 만에 탈출했는데 어떤 픽셀은 800번을 버텨야 한다면, 301번째부터는 계산할 방법이 없습니다.
더 미묘한 문제도 있습니다. 픽셀의 궤도 이 0 가까이 지나가면 과 이 비슷해지면서 상쇄가 일어나고, 이웃 픽셀들이 같은 값으로 뭉쳐 버립니다. 화면에는 무늬 없이 평평한 얼룩이 생깁니다. 딥 줌 커뮤니티는 이것을 글리치(glitch) 라고 불렀습니다. 2014년 무렵 Pauldelbrot가 글리치를 찾아내는 판정법을 만들었고, 오랫동안 표준 해법은 "글리치가 난 영역에 기준점을 하나 더 두고 다시 계산하기"였습니다. 화면 하나에 기준 궤도가 수십 개 필요한 경우도 흔했습니다.
리베이스: 처음으로 돌아가기
2021년 Zhuoran이 제안한 리베이스(rebasing) 는 이 문제를 한 줄로 풀었습니다. 조건은 이렇습니다.
픽셀의 실제 위치 이 기준 궤도와의 차이 보다 원점에 더 가까워지면, 기준 궤도의 처음으로 돌아가서 따라가기를 다시 시작합니다. 기준 궤도의 첫 점은 이므로, 그 순간의 자체를 새 로 쓰면 됩니다. 이때는 이 작으므로 새 의 크기도 작아서 섭동이 잘 성립합니다.
이 한 줄의 의미는 생각보다 큽니다. 픽셀은 이제 기준 궤도의 어느 구간이든 빌려 쓸 수 있습니다. 기준점이 300번 만에 탈출해도, 그보다 오래 버티는 픽셀은 0 근처를 지날 때마다 궤도의 앞부분으로 돌아가 다시 따라가면 됩니다. 기준 궤도가 바닥나는 경우도 같은 방식으로 처리합니다. 작품의 셰이더에서는 이렇게 쓰입니다.
if (index >= int(uLength) - 2 || r2 < dot(delta.xy, delta.xy)) {
q = z; exponent = uDepth; index = 0; // zₙ 자체가 새 δ, 궤도의 처음부터
}리베이스 덕분에 작품은 화면 하나에 기준 궤도 하나만 씁니다. 글리치 판정도, 보조 기준점도, 다시 그리기도 없습니다. 기준점이 화면 가운데에 있을 필요도 없어서, 뒤에서 볼 카메라 이동과 손으로 끌기가 가능해집니다.
처음 수천 번을 한 번에
급수 근사
512옥타브에서는 경계 근처의 픽셀이 3,800번가량 반복한 뒤에야 탈출합니다. 그런데 처음 수천 번 동안 화면의 모든 픽셀은 거의 같은 길을 갑니다. 이 아주 작아서 항이 무시할 만할 때는, 점화식이 에 대한 일차식이 되기 때문입니다.
은 픽셀과 무관하게 기준 궤도만으로 정해지는 복소수 계수입니다. 그러니 을 CPU에서 한 번 계산해 두면, 셰이더는 0번이 아니라 번째 반복에서 로 출발할 수 있습니다. 이것이 급수 근사(series approximation) 입니다.
언제까지 건너뛰어도 될지는 버린 이차항이 얼마나 커지는지에 달려 있습니다. 작품은 화면 전체를 덮는 반지름 에 대해 의 상한 과, 버린 비선형 항의 상한 을 함께 키워 갑니다.
이 선형 항 의 배를 넘거나 이 0.01을 넘는 순간 멈춥니다. 화면 한가운데 픽셀 하나가 아니라 화면 전체, 그것도 움직이는 카메라 오프셋까지 포함한 영역에 대한 보수적인 상한입니다.
| 깊이 | 반복 한도 | 기준 궤도 길이 | 건너뛰는 반복 | |
|---|---|---|---|---|
| 32 | 992 | 993 | 3 | |
| 64 | 1,184 | 1,185 | 256 | |
| 128 | 1,568 | 1,569 | 837 | |
| 256 | 2,336 | 2,337 | 1,438 | |
| 512 | 3,872 | 3,862 | 2,683 |
512옥타브에서는 기준 궤도의 70%를 건너뜁니다. 그런데 마지막 열을 보면 또 지수 문제가 있습니다. 은 float32의 최댓값 을 훌쩍 넘습니다. 그래서 역시 와 로 쪼개 셰이더에 넘깁니다. 셰이더는 dc, 지수 에서 출발하는데, 이것이 정확히 입니다. 앞에서 만든 '지수를 따로 들고 다니는 수'가 여기서도 그대로 쓰입니다.
FIG. 4의 보라색 음영이 이 건너뛴 구간입니다. 깊이를 올려 보면 음영이 그래프의 절반 이상을 덮습니다.
어디로 내려갈 것인가
고정된 목표의 함정
지금까지는 어떻게 계산하느냐의 문제였습니다. 남은 질문은 어디를 확대하느냐입니다. Mandelbrot 집합은 거의 모든 곳이 텅 비어 있습니다. 경계에서 조금만 벗어나면 매끈한 바깥이고, 조금만 안으로 들어가면 새까만 내부입니다. 아무 점이나 512옥타브 확대하면 대부분 단색 화면이 나옵니다.
작품의 첫 버전은 미시우레비치 점을 목표로 삼았습니다. , 곧 0에서 출발한 궤도가 18번 만에 고정점에 내려앉는 특별한 입니다. 이런 점은 정확히 경계 위에 있어서 아무리 확대해도 무늬가 사라지지 않습니다. 작품은 이 방정식을 BigInt 뉴턴법으로 풀어서, 정밀도가 오를 때마다 같은 점을 더 정확하게 다시 구했습니다.
문제는 너무 잘 작동했다는 데 있습니다. Tan Lei(1990)가 증명했듯이 미시우레비치 점 근처의 Mandelbrot 집합은 점근적으로 자기유사합니다. 일정한 배율마다 거의 같은 무늬가 돌아옵니다. 512옥타브 내내 같은 모티프가 반복되는 여정은 수학적으로는 아름답지만 감상하기에는 지루했습니다.
4옥타브마다 정찰하기
지금의 작품은 해마 골짜기 근처에서 출발해 가는 길에 무엇이 있는지 보고 방향을 정합니다. Worker는 4옥타브마다 현재 웨이포인트 주변에 33×33 격자를 깔고, 격자점마다 섭동으로 탈출 시간을 잽니다. 그리고 각 격자점에 점수를 매깁니다.
대비는 상하좌우 이웃과의 탈출 횟수 차이입니다. 무늬가 촘촘한 곳일수록 높습니다. 탈출 횟수가 크다는 것은 경계에 가깝다는 뜻이고, 거리 벌점은 카메라가 옆으로 너무 크게 휘지 않게 합니다. 끝까지 탈출하지 않은 점은 내부이므로 후보에서 뺍니다. 점수가 가장 높은 점이 다음 웨이포인트가 됩니다.
웨이포인트 좌표는 768비트 고정소수점 정수로 저장합니다. 512옥타브에 128비트 여유를 더하고도 남는 길이입니다. 카메라는 두 웨이포인트 사이를 5차 다항식 으로 미끄러집니다. 이 곡선은 양 끝에서 속도와 가속도가 모두 0이라서, 구간이 바뀌는 순간에도 카메라가 덜컥거리지 않습니다.
정찰은 확대보다 1.5옥타브 앞서 진행하고, 다음 구간이 준비되지 않았으면 재생이 잠시 기다립니다. 처음부터 512옥타브까지 한 번에 뛰어들려면 앞의 경로 전체를 정찰해야 해서 약 17초가 걸리지만, 평소처럼 내려가면 그 작업이 여정 전체에 흩어집니다.

96옥타브 · ×7.9×10²⁸

160옥타브 · ×1.5×10⁴⁸

320옥타브 · ×2.1×10⁹⁶

480옥타브 · ×3.1×10¹⁴⁴
이음매 없이 영원히 — Julia 루프
Mandelbrot 모드는 512옥타브에서 멈춥니다. 정밀도를 더 늘리면 더 갈 수 있지만, 유한한 수로는 언젠가 끝이 옵니다. 작품의 두 번째 모드인 Julia는 다른 방법으로 끝을 없앱니다. 같은 장면으로 정확히 돌아오는 확대입니다.
반발 주기점
Julia 집합은 Mandelbrot과 같은 식 에서 를 하나로 고정하고, 대신 출발점 를 화면의 좌표로 쓴 그림입니다. 작품은 를 씁니다.
이 동역학에는 주기점이 있습니다. 55번 반복하면 제자리로 돌아오는 점 , 곧 ()인 점입니다. 근처의 작은 변위 는 한 주기를 돌고 나면 대략 배가 됩니다.
이면 근처의 점들은 한 주기마다 배씩 멀어집니다. 이런 점을 반발 주기점이라고 하고, 반발 주기점은 언제나 Julia 집합 위에 있습니다. 그리고 여기서 핵심적인 사실이 나옵니다. 에서 출발한 점은 55번 뒤에 근처에 와 있으므로, 에 있는 점은 에 있는 점보다 한 주기(55번) 먼저 같은 운명을 맞습니다. 그러니 를 중심으로 화면을 배 확대하면, 탈출 횟수가 모두 55씩 늘어난 같은 그림이 나옵니다.
작품의 주기점은 , 곧 옥타브입니다. 다만 는 복소수라서 크기뿐 아니라 각도(약 )도 있습니다. 화면을 배 확대하는 동시에 그만큼 회전시켜야 같은 그림이 됩니다.
무한한 기준 궤도
이 구조는 앞의 섭동과 그대로 이어집니다. 주기점의 궤도 를 기준 궤도로 삼고, 픽셀은 그 궤도에서의 변위만 계산합니다. Mandelbrot 때와 다른 점은 이 기준 궤도가 절대 탈출하지 않는다는 것입니다. 55번째에서 다시 로 돌아오므로, 인덱스를 0으로 되감기만 하면 영원히 따라갈 수 있습니다.
vec2 delta = mul(w, seed); // seed = λ⁻¹ × 카메라 배율·회전
for (int i = 0; i < 780; i++) {
delta = 2. * mul(uCycle[index], delta) + mul(delta, delta);
index++; if (index == int(uPeriod)) index = 0; // 궤도는 끝나지 않는다
vec2 z = uCycle[index] + delta; r2 = dot(z, z);
if (r2 > 65536.) break;
}
float band = TAU * smoothCount / uPeriod; // 55회마다 색이 한 바퀴카메라는 깊이 ()에서 배율 와 회전 을 씁니다. 가 54.76에 닿으면 배율과 회전이 합쳐서 정확히 이 되고, 이 화면은 의 화면과 같습니다. 그래서 깊이를 54.76으로 나눈 나머지만 셰이더에 넘기면 됩니다. 기본 속도로 약 94초마다 장면이 처음으로 돌아오는데, 그 이음매는 보이지 않습니다.
색도 같은 주기를 따라야 합니다. 확대 한 주기마다 탈출 횟수가 55씩 늘어나므로, 색 띠를 TAU * smoothCount / 55로 정하면 색까지 함께 돌아옵니다. 다른 주기로 칠했다면 형태는 이어져도 색이 이음매에서 툭 바뀌었을 것입니다.
이 주기점은 우연히 고른 것이 아닙니다. 21, 34, 55주기 후보를 각각 240개씩 역방향 분기로 찾고, 짧은 주기의 반복이거나 승수 크기가 부적절한 것을 걸러 낸 뒤, 남은 후보를 GPU로 그려 비교했습니다. 55주기를 고른 이유는 한 바퀴의 여정이 길고(55옥타브) 회전이 적당하기 때문입니다.

Julia · 한 주기의 37%

Julia · 한 주기의 73%
반복 횟수가 표면이 되기까지
지금까지 얻은 것은 픽셀마다 '몇 번 만에 탈출했는가'라는 정수 하나입니다. 이것을 그대로 색으로 바꾸면 계단 같은 띠가 생기고, 깊은 곳에서는 픽셀보다 촘촘한 무늬가 반짝거립니다. 작품은 네 단계를 거쳐 이 정수를 매끄러운 표면으로 바꿉니다.
① 부드러운 반복 횟수. 탈출한 순간의 에는 '얼마나 여유 있게 탈출했는가'라는 정보가 남아 있습니다. 가 충분히 크면 한 번 반복할 때마다 가 두 배가 되므로, 다음 값은 정수 횟수 사이를 연속적으로 메웁니다.
작품의 탈출 반지름이 2가 아니라 256()인 것도 이 때문입니다. 가 클수록 의 영향이 작아져서 위 근사가 정확해지고, 띠의 이음매가 사라집니다.
② 거리 추정. 섭동 루프와 함께 도함수 도 굴립니다. 입니다. Milnor의 거리 추정은 이 도함수로 이 픽셀이 집합에서 얼마나 떨어져 있는지를 알려 줍니다.
가 픽셀 크기보다 훨씬 작으면 그 픽셀 안에 경계의 가는 실이 여러 가닥 지나간다는 뜻입니다. 작품은 가 0.04–0.65픽셀인 구간에서 색을 경계색으로 서서히 흐립니다. 그러면 계산할 수 없는 미세한 무늬가 반짝이는 대신 차분하게 가라앉습니다. 깊은 곳에서는 도 을 넘어가므로, 도함수 역시 가수와 지수를 따로 들고 다닙니다. 경계 근처(픽셀)의 픽셀은 중심에서 대각선으로 ¼픽셀씩 비껴 난 두 지점을 계산해 평균합니다.
③ 부조 조명. 의 방향은 등고선에 수직인 '바깥쪽'을 가리킵니다. 이것을 표면의 기울기로 삼고, 로 반복 횟수마다 골과 마루를 만들면 홈이 파인 금속판 같은 표면이 생깁니다. 여기에 고정된 광원의 확산광과 좁은 반사광을 더한 것이 Sapphire 팔레트입니다. 실제 3D 기하가 아니라 2D 위에서 법선만 흉내 낸 조명입니다.

01 Patina

02 Sapphire

03 Ocean

04 Atelier
전체 흐름
세 층은 서로 다른 속도로 돕니다. Worker는 다음 구간의 웨이포인트와 기준 궤도를 1.5옥타브 앞서 준비해 Float32Array로 넘기고(복사 없이 transfer), 메인 스레드는 그 궤도를 RGBA float 텍스처 한 줄로 올립니다. 깊이가 바뀌면 급수 근사로 출발점을 다시 정하고, GPU는 그 출발점에서 섭동 루프를 돌립니다. 반복 한도는 로 깊이와 함께 늘어나, 512옥타브에서는 3,872회입니다. 화면은 최대 420만 픽셀(DPR 2)까지 그립니다.
얼마나 맞는가
섭동, 지수 분리, 리베이스, 급수 근사는 모두 근사이거나 근사에 기대는 기법입니다. 그래서 작품은 각 고리를 따로 확인합니다.
| 검증 항목 | 결과 |
|---|---|
| 기준 궤도 전환 지점(7, 11, 19, 35, 67옥타브)의 ±10⁻⁶옥타브 앞뒤 화면 | 평균 RGB 차이 0.007/255 미만 |
| Sapphire 팔레트, 3–512옥타브의 구간 이음매 | 평균 RGB 차이 0.019/255 미만 |
| Julia 루프 경계, 데스크톱 1440×1000 / 모바일 390×844 | 평균 채널 차이 최대 0.0068 / 0.0058 (255 기준) |
| Julia 한 주기를 64등분한 전 구간 | 빈 화면이나 평평한 화면 없음 |
| 경로 탐색 재생 | 같은 경로, 되감아도 같은 좌표 (FIG. 6의 '일치') |
| 얕은 깊이(16옥타브 이하) float64 직접 계산과 섭동 | 픽셀의 99.8% 이상 일치 |
FIG. 4는 한 픽셀에 대해 이 검증을 눈으로 보여 줍니다. 작품의 표현으로 구한 (노란 점선)은 float64 기준값(초록 실선)과 그래프 위에서 구별되지 않습니다.
마치며
한 픽셀의 색이 정해지기까지 지나온 길을 돌아보면 이렇습니다.
정찰한 웨이포인트(768비트) → BigInt 기준 궤도(640비트) → float32 두 개로 쪼갠 텍스처 → 급수 근사로 2,683회 건너뛰기 → (GPU) 가수 와 지수 로 쓴 섭동 → 0에 가까워지면 리베이스 → 탈출 → 부드러운 횟수, 거리, 법선 → 픽셀
여기서 실제로 정밀한 계산은 화면에 하나뿐인 기준 궤도뿐입니다. 나머지는 모두 '어디에 정밀도가 필요하고 어디에는 필요 없는가'를 가려낸 결과입니다. 유효숫자가 필요한 곳에는 double-single을 쓰고, 지수 범위가 필요한 곳에는 따로 든 정수 지수를 씁니다. 절대 위치가 필요한 곳에만 BigInt를 쓰고, 픽셀마다 필요한 것은 차이뿐입니다.
작품의 문구는 "하나의 무늬 안에 또 하나의 세계"입니다. 그 말은 시적 과장처럼 들리지만, 계산의 언어로 옮기면 꽤 정확한 서술입니다. 무늬 안의 세계는 실제로 거기 있고, 우리가 할 일은 그곳까지 가는 동안 숫자가 무너지지 않게 하는 것뿐입니다.
작품을 열고 스크롤을 굴려 보세요. 화면 아래의 옥타브 숫자가 50을 넘는 순간부터는 float64로도 볼 수 없는 곳입니다.
참고 자료
- K. I. Martin, "Superfractalthing Maths" (2013) — GPU 딥 줌을 위한 섭동과 급수 근사를 널리 알린 글.
- C. Heiland-Allen (mathr), "Deep zoom theory and practice" (2021) — 섭동과 급수 근사를 비롯한 딥 줌 기법 전반의 정리.
- Zhuoran, fractalforums.org의 리베이스 제안 (2021) — 한 개의 기준 궤도로 글리치를 없애는 방법.
- T. J. Dekker, "A floating-point technique for extending the available precision", Numerische Mathematik (1971) — double-single 산술과 분할 상수.
- J. Milnor, Dynamics in One Complex Variable — 반발 주기점과 선형화(쾨니히스 정리).
- H.-O. Peitgen, D. Saupe (eds.), The Science of Fractal Images (1988) — 거리 추정으로 경계를 그리는 방법.
- Tan Lei, "Similarity between the Mandelbrot set and Julia sets", Communications in Mathematical Physics (1990) — 미시우레비치 점과 반발 주기점 근처의 점근적 자기유사성.