셰이더 파서 개발 과정

Shell·2026년 3월 28일

해당 글은 ShellEngine의 셰이더 파서를 만들 때의 기억을 되살려 작성한 글입니다.

개요

렌더러를 구현하다 보면 렌더 파이프라인의 상태를 바꿔야 할 때가 많다.
예를들어 셰이더로 아웃라인 효과를 구현하려면 첫 번째 패스에서 스텐실 버퍼를 채우고, 두 번째 패스에서 스텐실을 비교하고, ColorMask와 Cull과 ZWrite도 같이 맞춰야 한다.
이런 상태에 대한 설정들을 코드가 아닌 외부 파일을 통해 동적으로 설정하고 싶었다.

Unity의 ShaderLab처럼 셰이더 코드뿐만 아니라 렌더 상태까지 같은 파일 안에 선언하고 엔진이 그걸 읽어 파이프라인에 반영하는 구조로 만들고 싶었다.

대학에서 배웠던..

구현하면서 컴파일러개론 수업의 내용을 떠올렸고 도움을 받았다.
당시엔 너무 수학적이고 이론적인 내용이라고 느꼈는데, 실제로 작은 언어를 만들어야 하는 상황이 오니까 그때 배운 것들이 도움이 됐다.

전체 파이프라인은 다음과 같다.

  • Lexer
  • Parser
  • ShaderAST
  • Generator
  • glslc
  • PassBuilder

Lexer가 문자열을 토큰으로 쪼개고, Parser가 토큰 흐름을 읽어 AST를 만들고, Generator가 실제 GLSL 코드를 뽑아낸다.(Vulkan은 GLSL을 쓸 수 있다)

수업에서 어휘 분석, 구문 분석, 중간 표현이라고 부르던 것들이다.
공부 할 때 너무 어려웠던 기억이 있는데, 이런 식으로 직접 쓰일 줄은 몰랐다.

뽑아낸 GLSL코드는 프로세스 실행으로 glslc.exe를 실행하여 컴파일한다.

처음엔 작게

이 기능은 처음부터 문법을 다 설계하고 구현한 것이 아니었다.
렌더러 기능이 필요해지면 새 문법을 만들었고, 쓰다 보면 버그가 나왔고, 고치면서 완성해갔다.

Git히스토리를 보면 이런 발전 과정이 있었다.

2025-01-02 StencilState 도입 - 렌더 상태를 담을 자리가 생겼다.
2025-01-11 커스텀 셰이더 문법 아키텍처 - Parser, Generator, PassBuilder 등장
2025-01-18 ShaderLexer 분리 - 문법이 커지자 토큰화가 필요해졌다.
2025-09-13 ZWrite가 Cull 토큰으로 읽히던 버그 수정
2025-09-13 로컬 sampler2D 지원
2025-12-01 constexpr 문법, specialization constant 확장
2025-12-28 ZTest 문법 추가

Lexer의 중요성

처음엔 파서 하나로 시작했다. 문자열을 직접 비교하면서 키워드를 판별했는데, 문법이 조금만 늘어나니까 금방 버거워졌다.
Property, MVP, LIGHT, VERTEX, Stencil, Cull... 예약어가 쌓일수록 파서 코드 안에 문자열 처리가 섞여서 뭘 고치면 다른 곳이 깨지고 수정이 힘들었다.

그래서 대학에서 배운대로 Lexer를 분리했다. 소스를 먼저 토큰으로 바꾸고, 파서는 토큰만 보게 했다.
분리하고 나니 파서 함수들이 읽기 쉬워지고 수정이 용이해졌다.

AI로 리팩토링

파서는 한 번도 만들어본 적 없는 코드라 구조가 적절한지 계속 의문이 들었다. 처음 짰던 코드는 대략 이런 모양이었다.

auto ShaderParser::CheckFunction(int startPos) const -> int
{
    int p = startPos;
    if (p + 2 >= tokens->size())
        return 0;

    if ((*tokens)[p].type == TokenType::Identifier &&
        (*tokens)[p + 1].type == TokenType::Identifier &&
        (*tokens)[p + 2].type == TokenType::LBracket)
    {
        p += 3;
        bool closeBracket = 0;
        int braceNested = 0;
        while ((*tokens)[p].type != TokenType::EndOfFile)
        {
            if ((*tokens)[p].type == TokenType::LBrace)
            {
                if (!closeBracket) return 0;
                ++braceNested;
            }
            // ... p를 직접 조작하며 계속 이어짐
        }
    }
}

p라는 인덱스 변수를 직접 조작하면서 토큰을 하나씩 읽어가는 방식이었다.
문제는 토큰을 읽는 로직과 문법을 해석하는 로직이 한 함수 안에 뒤섞여 있다는 점이었다.
새 문법을 추가하거나 기존 규칙을 고칠 때마다 p가 어디를 가리키는지 머릿속으로 따라가야 했다.

이 구조가 괜찮은 건지 GPT에게 보여줬다.
당시엔 LLM의 코딩 성능이 지금보다 훨씬 안 좋았지만, 구조적인 개선 방향을 제안받기엔 충분했다.
핵심 조언은 "토큰 하나를 소비하는 함수를 별도로 분리하고, 각 문법 규칙을 독립된 함수로 나눠라"였다. 전형적인 재귀 하강 파서 구조였다.

(그 당시 찍어둔 답변이다.)

그래서 개선했다.

void ShaderParser::Parse()
{
    while (!IsAtEnd())
    {
        if (CheckToken(ShaderLexer::TokenType::Identifier))
            ParseFunction();
        else
            NextToken();
    }
}

개선 후에는 ConsumeToken(), PeekToken(), NextToken() 같은 함수가 토큰 이동을 담당하고, ParseStencil(), ParseCull(), ParseZWrite() 같은 함수는 각자 맡은 문법 규칙만 본다.
새 문법을 추가할 때 기존 함수를 건드릴 필요가 없어졌다.

완성된 결과

해당 파서를 만들고 렌더러를 수정해서 나온 결과물은 만족스러웠다.
아웃라인 셰이더를 코드 수정 없이 텍스트 파일 하나로 쓸 수 있게 됐다.

Pass
{
    LightingPass "Opaque"
    ColorMask 0;

    Stencil
    {
        Ref 1;
        Comp Always;
        Pass Replace;
    }
    // ... 버텍스/프래그먼트 코드
}

Pass
{
    LightingPass "Transparent"

    Stencil
    {
        Ref 1;
        Comp NotEqual;
        Pass Keep;
    }
    // ... 아웃라인 그리는 코드
}

파일을 열면 이 셰이더가 어떻게 동작하는지 바로 보인다.
렌더 상태를 추적하려고 C++ 코드를 뒤질 필요가 없어졌다.

마무리

이 파서는 현재 토큰과 그 뒤 몇 개를 미리 보는 LL 파서 방식이다.

현대에는 상향식 파서를 더 많이 쓴다고 하는데, 지금까지는 별 문제가 없었다.
아직 그렇게 복잡한 문법이 필요하지 않아서일 것이다. 만일 필요해지면 그 때 구조를 바꾸면 되지 않을까..

참고
LL 하향식 파서는 아래와 같은 좌재귀를 처리하지 못한다.
Expr -> Expr + Term | Term

덤으로, 해당 구조를 좀만 수정하면 비주얼 스크립팅도 가능할 것 같다. 여유가 생기면 만들어봐야겠다.

배운점

  • 함수의 역할 분리 하나로 코드의 가독성이 많이 향상 된다는 점
  • 파서 구조
profile
개발하며 배웠던 것들 기록용 블로그

0개의 댓글