1P by GN⁺ | ★ favorite | 댓글 1개
  • CSS box-shadow는 보통 UI에 깊이감을 주는 속성이지만, 단일 div에 수백~수천 개를 겹치면 일종의 그리기 API처럼 동작함
  • 상자라는 제약이 있어도 둥근 모서리, 여러 그림자 체인, 색상·블러·투명도 조합으로 원형 점과 레이어 효과를 만들 수 있음
  • 실험에서는 블러와 투명도가 애니메이션 가능한 개수를 크게 줄였고, 이를 빼면 M1 노트북에서 수천 개의 box-shadow를 그릴 수 있었음
  • 공 시뮬레이션, 이미지 픽셀, 큐브·구 표면 점, 음악 반응형 시각화는 모두 매 프레임 box-shadow 문자열을 갱신하는 방식으로 구현됨
  • 마지막에는 CPU 기반 레이 트레이서를 Web Workers로 병렬화해, 단일 div와 box-shadow만으로도 장면 렌더링이 가능함을 보여줌

box-shadow를 그리기 도구처럼 쓰기

  • CSS box-shadow드롭 섀도(drop shadow) 의 한 형태로, 원래는 이미지나 UI 요소 뒤에 그림자를 그려 깊이감을 주는 데 쓰임
  • 일반적인 드롭 섀도는 이미지의 래스터를 x/y축으로 이동시키고, 단일 색상으로 원본 뒤에 그려 깊이감을 만듦
  • CSS filter: drop-shadow()는 x/y 오프셋과 색상을 받으며, 선택적으로 blur 값도 지원함
  • 여러 드롭 섀도 필터를 레이어링하면 화면 구성에 깊이감을 더할 수 있음

box-shadow의 특성과 제약

  • box-shadow의 “Box”는 그림자 모양이 컨테이너의 bounding box에 제한된다는 뜻임
  • 이 제약은 제한적으로 보이지만, 대부분의 UI가 박스로 구성되어 있어 실제 UI에서는 유용함
  • CSS box-shadow 구현은 둥근 박스를 저렴하게 그리는 수학적 최적화를 지원하며, 둥근 박스는 원처럼 보일 수 있음
  • 디자이너는 미리 렌더링한 이미지를 쓰지 않고도 box-shadow를 활용해 다운로드 크기 증가를 피할 수 있음
  • 단일 div에는 여러 box-shadow를 체인처럼 붙일 수 있고, 예제는 이 방식으로 색상과 그림자를 구성함
  • border-radius 조정은 CSS border-radius generator에서 실험할 수 있음

“잘못된” box-shadow 사용법

  • 일반적인 UI 디자인에서 그림자는 여백, 패딩, 타이포그래피, 접근성과 함께 상태와 상호작용을 구분하는 보조 요소로 쓰임
  • 하지만 box-shadow는 임의 개수의 사각형을 화면에 칠하고 선택적으로 blur를 적용하는 페인팅 API처럼 다룰 수 있음
  • 초기 실험은 이전 글의 미니멀 아트에서 시작됐고, 간단한 색상 블록 설정만으로 시각적 결과를 만들었음
  • 이후 더 많은 box-shadow, blur, 투명도가 성능에 미치는 영향을 보기 위해 시각화 도구를 만듦
    • 큰 box-shadow 문자열을 생성해 하나의 div에 설정함
    • 애니메이션은 300ms마다 box-shadow 문자열을 바꾸고 transition: all에 맡기는 방식으로 시작함
    • 이 방식은 jank가 있었고, 매 프레임 box-shadow를 설정하는 것보다 느렸음
  • 100개 box-shadow 예제는 색상 팔레트를 탭으로 리믹스하고, 왼쪽에 최근 10개 팔레트 기록을 보여줌
  • blur를 적용하면 애니메이션 가능한 수가 줄었고, 투명 색상도 그릴 수 있는 개수를 크게 줄였음
  • div 크기도 성능에 영향을 줬으며, 애니메이션 시 소프트웨어 래스터라이저가 관련된 것처럼 보였음
  • 투명도와 blur를 쓰지 않으면 M1 노트북은 수천 개의 box-shadow를 그릴 수 있었음

box-shadow로 공 시뮬레이션 만들기

  • box-shadow는 회전할 수 없지만, border-radius를 이용하면 원처럼 보이게 만들 수 있음
  • 여러 원형 그림자를 공처럼 다루고, z값에 따라 크기를 스케일링해 가짜 3D 깊이감을 만들었음
  • 구현은 requestAnimationFrame에서 게임 상태를 갱신하고, 매 프레임 큰 box-shadow 문자열div에 설정하는 방식임
  • 렌더링 과정은 다음과 같음
    • 공을 z값 기준으로 정렬함
    • z값에 따라 크기를 계산함
    • 각 공의 x/y 위치, spread, 색상을 box-shadow 항목으로 변환함
    • 항목들을 쉼표로 이어 하나의 문자열로 만듦
  • 50개 공 예제는 드래그로 공을 움직이고, 박스 안에서 튕기게 만듦
  • 가짜 3D 스케일링은 어느 정도 깊이감을 주지만, 공이 “카메라”에 가까워지면 원이 깨져 보일 수 있음
    • box-shadow를 만드는 기준 div가 스케일링 방식에 비해 너무 작기 때문임
    • 컨테이너 크기를 키우면 해결되지만, 컨테이너가 커질수록 성능은 느려짐
  • 공끼리 충돌하게 하는 버전은 n^2 충돌 검사를 사용하고, 충돌 시 속도만 반사함
    • 실제 물리 상호작용을 시뮬레이션하지는 않음
    • 보기 쉽게 z 위치는 고정해 2D로 만들었음
  • 공들이 무작위 시작 위치로 돌아가려는 예제도 만들었으며, 터치 힘으로 공들을 끌어당기면 스펀지에서 조각을 떼어내는 듯한 효과가 생김

이미지와 3D 점 구름

  • 다음 실험은 이미지 픽셀을 2D 평면의 점으로 매핑하고, 각 점을 box-shadow로 그리는 방식임
  • 이미지 픽셀의 위치와 색상을 읽어 state.particles에 저장하고, 각 픽셀을 하나의 입자로 사용함
  • 소스는 CodeSandbox 예제에 있음
  • 이 예제는 수천 개의 box-shadow를 3D 공간에서 렌더링하며, 드래그하면 이미지를 흩뜨리는 듯한 상호작용이 가능함
  • 자동 회전 라이브 버전은 /box-shadow/v3?width=80&size=5&autoRotate=1로 제공되며, 실행 시 배터리 소모에 대한 경고가 붙어 있음
  • 더 높은 개수의 예제는 약 12,000개 box-shadow 규모였고, 이 수준에서는 버벅이는 모습을 보였음
  • M1에서는 매우 많은 box-shadow를 처리했지만, 데스크톱, iPhone, 오래된 Android는 같은 수준을 처리하지 못했음

큐브와 구 표면에 점 배치하기

  • 점들을 메시 표면에 균일하게 투영하는 실험도 이어짐
  • 큐브는 각 면을 따라 점을 배치하는 방식으로 구현됨
    • 모든 변의 길이가 같다고 가정함
    • 각 면을 순회하며 주어진 크기에 따라 점을 채움
    • 점 수를 늘리면 큐브의 정밀도를 높일 수 있음
  • 큐브 예제는 탭으로 상호작용할 수 있고, 마우스 위치를 따라가는 작은 조명을 추가해 깊이감을 더함
  • 조명 계산은 정확하지 않으며, 여러 “magic constants”를 사용함
  • 큐브 입자 매핑 함수와 여러 수학 도우미는 gypity를 사용해 얻었음
    • 첫 번째 결과는 무작위 분포였음
    • 두 번째 시도에서 큐브 표면의 균일 배치를 얻음
  • 구 표면의 균일 배치는 더 복잡해 spiral discretization 방식을 사용함
    • 아래에서 위로 구를 감싸는 선 위에 점들을 균일하게 배치하는 아이디어임
    • 밧줄이 공을 감싸는 모습에 비유됨
  • 이 선형 매핑을 이용해 음악의 주파수 값 배열과 연결한 애니메이션도 만들었음
  • 점들에는 시간에 따라 보간되는 그라디언트 팔레트 애니메이션을 적용했고, 이 부분이 앞선 작업보다 더 어려웠음
  • 회전 수가 적은 구 예제는 나선이 균일 분포를 근사하지만, 충분한 회전 수가 없으면 깨지는 모습을 보여줌

box-shadow 레이 트레이서

  • 삼각형을 box-shadow로 그릴 수 있다면 거의 모든 CGI를 만들 수 있다는 생각이 레이 트레이싱 실험으로 이어짐
  • 목표는 단일 div와 box-shadow만으로 장면을 그리는 것이었음
  • 이후 예제들은 낮은 정밀도 라이브 렌더링과 높은 정밀도 이미지 렌더링을 함께 사용하며, 정밀도 제한을 두지 않은 예제도 있어 실행 주의가 필요함
  • 레이 트레이서와 레이 마처는 정확하지만 느린 이미지 생성 방식이며, CGI 업계 전반에서 쓰임
  • 여기서는 GPU 대신 CPU 기반 tracer를 사용함
    • GPU를 쓰면 실험의 취지가 약해지고 구현도 어렵기 때문임
  • 초기 tracer는 구만 렌더링하도록 만들었고, 장면 데이터 모델에는 카메라, 여러 구, 재질 정보가 포함됨
  • gypity로 받은 초기 코드는 실시간으로 동작했지만 문제가 있었음
    • 샘플링 최적화에서 필요한 bias coefficient를 더하는 코드가 빠져 있었음
    • 관련 주석은 있었지만 실제 한 줄이 없었고, 샘플링 기법을 찾아본 뒤 고쳤음

progressive rendering과 Three.js

  • 이후 코드는 progressive rendering 구조로 바뀜
  • progressive rendering은 많은 ray 계산을 여러 프레임에 나눠 수행해, 렌더링이 점점 “ground truth”에 가까워지는 과정을 보여줌
  • 이 구조는 카메라를 상호작용식으로 움직이는 기능과 잘 맞음
  • 카메라와 orbit controls는 Three.js 라이브러리를 사용함
    • 직접 행렬 수학과 모바일 지원을 구현하지 않기 위해서임
  • 해당 버전은 구만 렌더링할 수 있으며, 장면의 모든 요소가 어느 정도 스케일된 구임
  • 예제는 기본적으로 전체 해상도의 약 6% 조금 넘는 수준에서 실행됨
  • 화면에서 멀어질수록 낮은 해상도의 장면이 더 선명해 보이고, 가까이 보면 정밀도 부족이 더 뚜렷해짐
  • 쿼리 파라미터로 해상도 스케일, pixelSize, ray bounce 수, 최대 샘플 수를 조정할 수 있음
  • 해상도와 샘플을 올리면 결과는 좋아지지만 매우 느려짐

tracer 구현과 성능 병목

  • 렌더링은 각 픽셀마다 카메라 기준 ray를 쏘고, 색상을 계산한 뒤 프레임 간 평균을 내는 표준 방식임
  • tracer는 Three.js를 사용했기 때문에 많은 새 객체가 만들어지고 빠르게 가비지로 쌓임
  • 객체 재사용을 일부 시도했지만, 성능을 끝까지 짜내려면 Three.js를 쓰지 않는 편이 나았음
  • 프로파일러 기준으로 가비지 수집이 큰 차이를 만들지는 않았고, Three.js 사용은 유지됨
  • tracer의 기본 아이디어는 ray가 장면의 객체를 맞고 빛에 도달할 때까지 튕기며, 객체와 조명 속성에 따라 색상을 반환하는 것임
  • 많은 ray를 쏴야 하는 이유는 ray가 광원에 닿지 않는 경우도 있고, 닿는 경우도 있기 때문임
  • 이 tracer는 단순한 조명 모델을 사용함
    • 물리적으로 정확한 BRDF 없음
    • 텍스처 없음
    • subsurface scattering 없음
    • 단순 확산광과 specular reflection 사용
  • 레이 트레이싱 학습 자료로 Ray Tracing in One Weekend를 볼 수 있음
  • 이후 plane을 렌더링할 수 있게 되었고, floating point 값을 bit 조작하는 실수를 고친 뒤 조명 사각형도 렌더링할 수 있게 됨

Web Workers로 병렬화하기

  • 성능 개선은 Three.js GC 문제를 고치는 대신 Web Workers 멀티스레딩으로 접근함
  • ray tracing은 각 계산이 단일 결과를 반환하고 부작용이 없어 병렬화에 잘 맞음
  • worker manager는 worker 풀을 만들고 render, updateScene 두 메서드를 제공해 런타임에 장면을 바꿀 수 있게 함
  • worker 코드는 기존 tracer를 복사한 형태이며, 장면 교체를 위해 데이터 마샬링이 필요했음
  • postMessage 오버헤드를 줄이기 위해 worker는 단일 픽셀이 아니라 전체 프레임을 렌더링함
  • 전체 화면 예제는 이전보다 훨씬 빠르게 동작함
  • 단점은 카메라나 장면이 바뀌면 기존 ray 계산 결과가 모두 무효가 되어 화면이 검게 보일 수 있다는 점임
  • 해결은 입력 이벤트에서 isDirty 플래그를 세우고, 업데이트 루프에서 프레임을 지울지 판단하는 방식으로 구현됨
  • worker가 이전 장면 데이터로 계산한 프레임을 장면 업데이트 직후 보낼 수 있는 경우가 있음
    • timestamp나 scene id를 postMessage에 포함해 버릴 수 있음
    • 실험에서는 한 프레임의 잘못된 ray 데이터가 곧 평균 처리되므로 그대로 둠
  • 결과적으로 box-shadow 기반 ray tracing은 충분히 동작했으며, 소스는 CodeSandbox에 있음
  • 성능 통계에는 총 ray 수 추정치, 샘플 수, 렌더링 해상도가 표시됨
  • 기본 샘플은 1200에서 멈추지만 설정 가능함
  • 더 빠르지만 덜 정확한 대체 조명 모델도 토글할 수 있음
  • 장면 데이터는 JSON이라 직접 수정하기 쉬움

결론: 가능하지만 권장되지 않는 실험

  • 단일 divbox-shadow만으로 공 시뮬레이션, 점 구름, 이미지 기반 입자, 큐브·구 표면, 레이 트레이싱 장면까지 그릴 수 있음
  • 다만 전체 실험은 실용적 사용 사례가 없는 “하지 말아야 할” 방식에 가까움
  • triangle mesh 로딩, acceleration structure, 더 정확한 조명 모델을 추가하면 개선 여지가 있음
  • gypity는 처음에 box-shadow ray tracer가 불가능하다고 답했지만, 실제 예제가 만들어졌음
  • CSS는 직관적이지 않지만, 때로는 명백하게 작동하는 이상한 가능성을 갖고 있음

댓글과 토론

Hacker News 의견들
  • 투명색을 쓰면 GPU 드로잉 배치 처리가 제한되기 때문에 느려짐
    불투명한 그리기는 깊이 버퍼를 써서 순서를 마음대로 바꿀 수 있고, 배치 처리를 극대화할 수 있음. 투명한 경우에는 블렌딩이 제대로 되도록 페인터 순서대로 그려야 함

    • 그보다 더 복잡하다고 봄. 웹 브라우저도 GPU 렌더링을 쓰지만 게임 엔진은 아니고, 화면의 모든 객체를 매 프레임 다시 그리면 크고 복잡한 페이지에서 쉽게 버벅일 수 있음
      특히 Chromium은 전체 레이어 수를 최소화하려고 하고, 각 레이어를 픽셀 맵으로 렌더링한 뒤 매 프레임 보이는 레이어들을 최종 이미지로 합성함. 실제로는 픽셀이 바뀌지 않고 위치만 움직이는 레이어가 많아서, 매 프레임 래스터화하지 않고 합성만 하면 됨
      투명도 없는 box-shadow가 많으면 Chromium이 전체를 한 번에 하나의 레이어로 래스터화할 수 있지만, 투명한 box-shadow가 많으면 각각을 별도 레이어로 만들 수도 있음. 이 경우에는 비효율적일 수 있지만, 반투명 box-shadow들이 페이지에서 독립적으로 움직여야 한다면 필요한 방식이기도 함
    • 역페인터 순서가 페인터 순서보다 나은 경우가 있음. 완전히 가려지는 객체를 건너뛸 수 있기 때문임
      완전히 투명한 버퍼(α=0.0)에서 시작해 앞에서 뒤로 각 면을 순회하고, 각 픽셀마다 새 픽셀의 1.0-buffer.α만큼을 기존 버퍼에 블렌딩하면 됨. buffer.α == 1.0이면 깊이 버퍼처럼 완전히 건너뛸 수 있음
      다만 투명 객체 뒤에 또 다른 투명 객체가 있을 때의 수학은 다시 확인해야 하고, 면들이 순환적으로 겹치거나 서로 관통하는 경우가 까다로움
    • GPU는 오버드로우도 싫어하므로, 투명 요소를 여러 겹 겹치는 건 피하는 편이 좋음
      투명 텍스처보다 삼각형을 더 많이 그리는 편이 대체로 나은 이유도 여기에 있음
    • 정렬 단계 자체는 보통 큰 문제가 아님. 드로우콜을 제출하기 전에 어차피 정렬을 하는 경우가 많음. 진짜 비용은 오버드로우에서 옴
      불투명 렌더링은 앞에서 뒤로 그릴 수 있어서 최종 프레임버퍼에 실제로 보이는 것만 렌더링하면 되고, 깊이 패스 뒤의 픽셀 수는 프레임버퍼 크기에 비례함
      투명 렌더링은 뒤에서 앞으로 그려야 하며, 나중에 다른 다각형에 부분적으로 가려질 장면까지 많이 렌더링해야 함. 그래서 셰이더 파이프라인을 통과하는 픽셀 수가 메시 크기에 비례할 정도로 커질 수 있음
      서로 겹치지 않는 요소라면 투명도 때문에 거의 느려지지 않을 것임. 어차피 각 픽셀을 한 번씩 만져야 하고 바뀐 건 셰이더 공식뿐이기 때문임
    • 고려할 게 하나 더 있는데, 특히 모바일 기기에서는 메모리 대역폭이 병목이 될 수 있음
      불투명 그리기가 다른 그리기 위에 올라가면 최선의 경우 겹치는 그리기 작업을 모두 제거할 수 있고, 최악의 경우에도 개별 그리기만큼의 대역폭만 쓰면 됨
      투명도에서는 연산을 어떻게든 합칠 수 없다면 겹치는 전체 영역을 다시 읽어야 하므로, 투명한 그리기마다 최종 프레임버퍼 크기의 최소 두 배에 해당하는 비트맵이 메모리 버스를 오가게 됨
      많은 모바일 기기가 60fps를 유지할 시간 안에 화면 전체를 두 번 블릿할 만큼의 메모리 대역폭도 부족했던 걸 생각하면 꽤 큰 문제가 됨
  • 정말 재미있는 탐구였음
    레이어링이 중요한 단어라는 것도 맞음. 14년 전 만든 텍스트 그림자 프로젝트의 어처구니없지만 가끔 멋져 보이는 효과도 레이어링이 핵심이었음: https://paulirish.github.io/mothereffingtextshadow/

    • 이 글도 여러 레이어가 있는 것 같고, 사실 box-shadow에 대한 글만은 아닌 듯함
  • 마지막 문단에 가서야 gypityChatGPT를 가리킨다는 걸 알아차려서 좀 민망했음

    • Primeagen YouTube 채널에서 쓰는 표현임
    • 이걸 보고 그 단어가 동료가 만든 내부 농담이 아니라, 밈이 되어가는 중인 표현이라는 걸 알게 됨
    • gyp 관련 클라이언트 사이드 도구 같은 건 줄 알았음
  • 좋은 옛날식 비실용 해킹은 완전히 환영함. 다만 이미 canvas가 있고, 이런 일은 canvas가 더 쉽고 빠르고 잘한다는 점은 기억해야 함

    • 하지만 코미디 값은 덜함. box-shadow만으로 이걸 하는 게 웃긴 이유는 그만큼 비실용적이기 때문임
    • Canvas는 특히 접근성에서 많은 걸 버리게 됨
    • Canvas가 더 낫긴 하지만, 고정 크기 상자 안에 머무르는 걸 만들 때의 얘기임
    • 물론 키보드도 이미 있으니, 플로피 드라이브나 고무 닭으로 음악을 만들 필요도 없다는 말과 비슷함
  • 음악 시각화는 확실히 멋졌음. 예전 Winamp 시절처럼 음악을 틀고 시각화를 전체 화면으로 띄우던 때가 정말 그리움
    요즘 스트리밍 오디오 플레이어도 이런 기능을 해줬으면 함

    • 아직도 Winamp, 정확히는 WACUP을 Milkdrop 시각화와 함께 거의 매일 씀
      다만 스트리밍 오디오 플레이어들이 정말 기본 기능밖에 없는 소프트웨어라는 건 맞음
  • 결국 Firefox와 Chrome은 브라우저 확대 150%에서 1px box-shadow를 아직도 다르게 렌더링함
    Baseline 2025에 기대를 걸어봄

  • “UI 사람들이 좋아하는 둥근 박스를 아주 싸게 그리는 수학 해킹”을 더 배울 만한 참고 자료가 있는지 궁금함

  • 내가 좋아하는 종류의 해킹임
    이 주제에서 읽었던 Josh Comeau 글들과 거의 정반대 같음: https://www.google.com/search?q=josh+comeau+shadows

  • 올해 읽은 글 중 최고일 수도 있는 훌륭한 글이 마지막에 you're 대신 your welcome으로 끝났음
    빨리 고쳐야 함. 아니면 농담을 못 알아들은 걸 수도 있음

  • 지난 30년 동안 프로그래밍은 잘하게 됐지만, 게임을 좋아하지 않아서 그래픽스는 거의 하지 않았음
    지금은 이걸 엄청난 실수로 보고 1년 넘게 따라잡으려 노력 중인데, 정말 어려움

    • 나는 게임을 먼저 하다가 CRUD나 단순한 Oracle Forms 프로그래밍으로 넘어갔고, 하룻밤 사이에 원래 게임 프로그래머 임금의 세 배를 받게 됨