Project Sienna: 3D 에어브러쉬 구현 도전의 기록

안녕하세요! 그래픽 엔지니어 전향을 위한 포트폴리오로, 3D 물체에 에어브러시 효과를 줄 수 있는 프로젝트인 Project Sienna (opens in a new tab)를 진행했습니다.

Project Sienna 데모 Project Sienna 데모: 마우스로 3D 물체에 페인트 적용하기

에어브러시 효과를 구현하면서 다양한 기술적 문제들을 겪고 해결하기를 반복했는데요, 웹 상에서 쉽게 해결책을 찾을 수 없는 재밌는 문제들도 많았습니다. 그래서 그 중 일부를 같이 공유해보려 합니다.

(데스크탑 환경에서 보고 계신 분들은 이 링크 (opens in a new tab)에서 데모를 해보시는 걸 추천드립니다!)

에어브러쉬의 페인트 효과 범위 구하기

이번 프로젝트에서 가장 처음 맞닥뜨린 도전은, 유저의 포인터 위치를 기준으로 적용할 브러쉬 페인트 효과 범위를 구하는 것이었습니다.

화면의 지점과 3D 물체의 교차 지점을 구할 때에는 일반적으로 Ray casting (opens in a new tab), 화면의 지점으로부터 광선을 3D 물체에 발사해 그 교차 지점을 수학적으로 구하는 방법을 사용합니다.

그러나 에어브러쉬는 원뿔 모양의 범위에 페인트를 뿌리기 때문에, 하나의 광선으로는 이 범위를 모두 표현하기 어렵습니다. 여러 광선을 사용할 수도 있지만, 이 경우 계산이 지나치게 복잡해지는 문제가 발생합니다.

브러쉬 효과 영역 카메라의 원리를 이용해 브러쉬 효과 영역 구하기 (Modified from Camera models (opens in a new tab))

그래서 이번 프로젝트에서는 브러쉬에 핀홀 카메라가 하나 있다 가정한 뒤, 카메라가 관찰할 수 있는 영역을 구하는 방식을 사용했습니다. 에어브러쉬는 브러쉬로부터 멀어질수록 더 넓은 범위에 페인트를 분사하게 되는데요, 핀홀 카메라도 마찬가지로 카메로부터 멀어질수록 더 넓은 범위를 볼 수 있게 됩니다. 따라서, 브러쉬의 시점에서 유저가 클릭한 방향으로 바라보는 핀홀 카메라가 관찰할 수 있는 영역을 구하면, 그 범위가 바로 에어브러쉬의 페인트 적용 범위가 됩니다.

위 방식을 활용해 유저가 마우스를 누르고 있는 순간마다 브러쉬를 위한 카메라 변수를 계산하고 이를 뒤에 언급할 쉐이더에 넘겨주었고, 이를 통해 에어브러쉬의 페인트 효과를 구현할 수 있었습니다. (연관 코드 링크 (opens in a new tab))

에어브러쉬 페인트 효과 텍스쳐에 적용하기

다음으로 만난 기술적 도전은 3D 물체에 에어브러쉬의 페인트 효과를 업데이트하는 것이었습니다.

에어브러쉬의 페인트 효과는 계속해서 누적되어야 하기 때문에 물체의 각 면마다 페인트 정보를 가지고 있어야 했는데요, 이를 위해 각 면마다 업데이트할 페인트 텍스쳐(Paint Texture)와 페인트칠된 텍스쳐(Painted Texture), 총 2가지의 2D 텍스쳐를 만들어 사용했습니다.

그리고 각 텍스쳐의 픽셀 별로 에어브러쉬의 페인트 효과를 계산해야 했는데요, 이를 위해 Paint Texture를 업데이트하는 쉐이더들을 작성했습니다.

Paint Texture Vertex Shader 코드
// Paint texture vertex shader
// 일부 Uniform 변수 선언문은 생략되었습니다.
 
layout (location = 0) in vec3 a_position;
layout (location = 1) in vec3 a_normal;
layout (location = 2) in vec2 a_texCoord;
 
out vec3 v_position;
out vec3 v_normal;
out vec3 v_projectedPosition;
out vec2 v_texCoord;
 
void main()
{
    v_texCoord = a_texCoord;
 
    vec4 modelPosition = u_model_matrix * vec4(a_position, 1.0);
    v_position = modelPosition.xyz;
 
    vec4 projectedPosition = u_brush_projectionMatrix * u_brush_viewMatrix * modelPosition;
    v_projectedPosition = projectedPosition.xyz / projectedPosition.w;
 
    mat3 normalMatrix = transpose(inverse(mat3(u_model_matrix)));
    v_normal = normalize(normalMatrix * a_normal);
 
    gl_Position = vec4(v_texCoord * 2.0 - 1.0, 0.0, 1.0);
}

Vertex shader에서는 보통 최종 좌표로 3D 물체의 clip space 내 좌표를 사용하는데요, 이번 쉐이더의 타겟은 텍스쳐이기 때문에, [-1, 1] 범위로 매핑된 텍스쳐 좌표를 넘기고, 향후 페인트의 효과 계산을 위해 넘겨주는 추가 인자에 3D 물체의 global space 내 좌표를 넘겨주도록 했습니다.

Paint Texture Fragment Shader 코드
// Paint texture fragment shader
// 일부 Uniform 변수 선언문은 생략되었습니다.
 
uniform sampler2D u_brushDepthTexture;
 
out vec4 FragColor;
 
in vec3 v_position;
in vec3 v_normal;
in vec3 v_projectedPosition;
in vec2 v_texCoord;
 
float g_intensity_coff = 0.05;
 
void main()
{
    float centerDistance = length(v_projectedPosition.xy);
 
    // Discard fragments outside the unit circle
    if (centerDistance - 1e-05 > 1.0)
    {
        discard;
    }
 
    float brushDepth = texture(u_brushDepthTexture, v_projectedPosition.xy * 0.5 + 0.5).r;
    float normalizedZ = v_projectedPosition.z * 0.5 + 0.5;
 
    // Discard fragments behind the brush
    if (normalizedZ - brushDepth > 2.0 * 1e-5)
    {
        discard;
    }
 
    float tanHalfFov = tan(u_brush_nozzleFov / 2.0);
    float distance = length(v_position - u_brush_position);
    vec3 normal = normalize(v_normal);
 
    float strength_coff = g_intensity_coff * u_brush_airPressure / (tanHalfFov * tanHalfFov * distance * distance);
    float normal_coff = max(0.0, dot(normal, normalize(u_brush_position - v_position)));
    float strength = strength_coff * normal_coff * (1.0 - smoothstep(0.0, 1.0, centerDistance));
 
    float baseIntensity = 2.0 / 1000.0 * u_time_delta_ms;
    float intensity = clamp(strength * baseIntensity, 0.0, 1.0);
 
    FragColor = vec4(u_brush_paintColor, intensity);
}

그리고 fragment shader에서 각 텍스쳐 좌표별 에어브러쉬의 색상 및 효과 강도를 계산하도록 해 Paint Texture의 각 텍스쳐 값을 구할 수 있게 했습니다.

위 shader로부터 나온 결과를 OpenGL의 framebuffer to texture (opens in a new tab) 기법을 사용해 스크린 대신 Paint Texture에 렌더링해 페인트 효과를 적용할 Paint Texture을 구할 수 있었습니다.

최종 페인트 효과 블렌딩하기

마지막 기술적 어려움은 Painted Texture를 업데이트할 때, 기존 페인트 색상을 자연스럽게 블렌딩하여 최종 페인트 효과를 구하는 것이었습니다.

다른 색의 에어브러쉬는 강도가 약할 때 기존 색과 섞이고, 강도가 강할 때는 기존 색을 덮어써야 합니다. 그런데 OpenGL에서 제공하는 기본 블렌딩 함수 (opens in a new tab)로는 이를 구현할 수 없었습니다.

Paint Blending Fragment Shader 코드
// Paint blending fragment shader
// 일부 Uniform 변수 선언문은 생략되었습니다.
 
uniform sampler2D u_paintMapTexture;
uniform sampler2D u_paintedMapTexture;
 
out vec4 FragColor;
 
in vec2 v_texCoord;
 
void main() {
    vec4 prevPaintedColor = texture(u_paintedMapTexture, v_texCoord);
    vec4 paintColor = texture(u_paintMapTexture, v_texCoord);
 
    float prevIntensity = prevPaintedColor.a;
    float paintIntensity = paintColor.a;
 
    float newIntensity = prevIntensity + paintIntensity * (1.0 - prevIntensity);
    newIntensity = clamp(newIntensity, 0.0, 1.0);
 
    float prevColorIntensity = prevIntensity * (1.0 - paintIntensity);
 
    float blendFactor = paintIntensity > 1e-05 ? paintIntensity / (prevColorIntensity + paintIntensity) : 0.0;
 
    vec3 newColor = mix(prevPaintedColor.rgb, paintColor.rgb, blendFactor);
 
    FragColor = vec4(newColor.rgb, newIntensity);
}

이 문제를 해결하기 위해 Paint Texutre와 기존 Painted Texture를 받아 신규 Painted Texture를 구하는 쉐이더(코드 링크 (opens in a new tab))를 하나 더 만들었습니다. 그리고 OpenGL에서는 업데이트 대상이 되는 framebuffer에 binding된 텍스쳐를 셰이더 내에서 사용할 수 없어 ping pong 텍스쳐 기법, 즉, 업데이트할 Painted Texture와 업데이트에 사용할 Painted Texture를 번갈아 가면서 사용하도록 했습니다.

이를 통해 다른 색상의 페인트칠도 자연스럽게 블렌딩할 수 있었습니다.


위 기술 난관들을 헤쳐나가면서 다양한 기법들을 사용해볼 수 있어 재밌었는데요, 다음에는 더 사실적인 페인트 효과를 향후 PBR(Physically Based Rendering)을 적용해볼 계획입니다.

또, texture 수정을 위해 framebuffer를 자주 스위칭하는데, 이는 렌더링 성능 저하를 일으킬 수 있다고 합니다. (opens in a new tab) 그래서 향후 이를 개선하기 위해 물체별 framebuffer 수를 줄이고 framebuffer 전환를 최소화할 수 있도록 렌더링 과정을 개선할 계획입니다.

혹시 이 프로젝트에 대한 피드백이나 질문이 있다면 Github Issue (opens in a new tab)에 남겨주시고, 코드도 자유롭게 참고 부탁드릴게요.

읽어주셔서 감사합니다!