포스트

URP Light2D + Parallax 청크 시스템: 3D 프로젝트에 값싼 2D 라이팅 적용기

3D 씬에 URP Light2D를 청크 기반 Parallax + 오브젝트 풀과 결합해 Directional/Light Probe 대비 저비용 라이팅을 구현한 실전 가이드. 렌더 순서, 라이트 타입·Blending·Normal Maps, ScriptableObject 데이터·SRDebugger 연동, 성능 팁과 코드까지.

URP Light2D + Parallax 청크 시스템: 3D 프로젝트에 값싼 2D 라이팅 적용기
Visitors

왜 3D 씬에 Light2D를? (문제정의 & 목표)

  • 문제정의: 대규모 3D 씬에서 Directional + Light Probe + 실시간 섀도우는 GPU/CPU/메모리 비용이 높음. 모바일/저사양에서 프레임 낙폭.
  • 핵심 목표
    • 시각 품질 유지: 글로벌 톤, 메인 포커스, 캐릭터 주변 라이트, 햇살(Shaft) 연출.
    • 낮은 비용: 2D 전용 라이트 패스 + 청크 트리거 + 오브젝트 풀로 스폰/업데이트/반납 비용 최소화.
    • 운영 친화: ScriptableObject + SRDebugger로 디자이너 및 기획측에서 런타임으로 라이트 등을 튜닝 가능하도록 툴 제공.

URP 2D Light 렌더링 순서 (개념 정리)

  1. 카메라 설정: 뷰포트/정렬(Sorting Layer & Order)
  2. 씬 오브젝트 렌더링: 스프라이트/메시 등 기하 렌더
  3. Light2D 렌더 패스: color, intensity, falloff, blend, mask기하 결과에 합성

핵심: 여러 Light2D가 겹치면 Blend StyleOverlap Operation(Additive / Alpha Blending)에 따라 최종 결과가 달라짐.


3D 프로젝트에서 Light2D를 동작시키는 렌더러 구성

Light2D는 URP의 2D Renderer 전용 기능입니다. 일반적인 3D 프로젝트(Universal Renderer)에서는 Light2D 컴포넌트를 추가해도 동작하지 않습니다. 3D 씬에서 Light2D를 쓰려면 렌더러 레벨의 설정이 필요합니다.

본 프로젝트의 렌더러 구성

flowchart TB
    subgraph Pipeline["URP Pipeline Asset"]
        direction TB
        PA["UniversalRenderPipelineAsset"]
        PA --> R1["Renderer2DData (2D Renderer)"]
    end

    subgraph Camera["카메라 구성"]
        direction TB
        MC["Main Camera<br/>Renderer: 2D Renderer"]
    end

    subgraph Rendering["렌더링 흐름"]
        direction TB
        S1["1. SpriteRenderer / Tilemap 렌더"]
        S2["2. Light2D 패스 (Blend Style별 RT 합성)"]
        S3["3. 3D Mesh는 Sorting Layer로 삽입"]
        S1 --> S2 --> S3
    end

    Pipeline --> Camera --> Rendering
설정 항목설명
Renderer TypeRenderer2D (2D Renderer)Light2D 동작의 전제 조건
카메라Single Camera + 2D Renderer3D 메시도 2D 렌더 파이프라인으로 처리
Sorting Layer3D 오브젝트를 적절한 Sorting Layer에 배치Light2D의 Target Sorting Layers와 매칭
베이스 해상도750×1334모바일 최적화 기준

핵심: 본 프로젝트는 탑다운 시점이라 3D 메시를 2D Renderer로 렌더링해도 시각적 차이가 거의 없습니다. 이것이 이 기법의 전제 조건입니다. 자유시점 3D 게임에서는 2D Renderer의 제약(Sorting 기반 깊이 처리, 3D 라이팅 부재)이 심각해지므로 이 접근법은 적합하지 않습니다.

Sorting Layer 구성

Light2D는 Target Sorting Layers로 영향 범위를 제한합니다. 본 프로젝트에서는 다음과 같이 구성합니다:

Sorting Layer대상Light2D 영향
Background배경 타일맵Global Light만 적용
Default3D 맵 오브젝트, 캐릭터Ambient + Main + Spot 모두 적용
EffectParallax 이펙트 (구름, 광선)이펙트 자체가 Light2D를 포함
Foreground전경 UI 요소Light 영향 없음

주의: 서로 다른 Sorting Layer를 대상으로 하는 Light2D끼리는 배치(batch)를 공유하지 않습니다. Target Sorting Layers 설정이 다른 Light2D가 많을수록 draw call이 늘어나므로, 가능한 한 동일한 Target 설정으로 통일하는 것이 성능상 유리합니다.


Light2D 타입·핵심 프로퍼티 요약

Light Type 요약

  • Global: 전역 톤(앰비언트)
  • Spot: 부채꼴/원뿔(Inner/Outer 각도 + Radius)
  • Freeform: 폴리곤으로 영역 지정
  • Sprite: 스프라이트 알파를 쿠키처럼 사용 (모양 기반 빛)

공통 프로퍼티

  • Color: 색. 어두운 색은 Intensity가 높아도 덜 밝아 보일 수 있음 → 색·세기 동시 조정
  • Intensity: 밝기(세기). 보통 0–1, 연출상 1 초과도 가능(겹침 과포화 주의)
  • Radius: 최대 범위(Spot/Point 중요). 너무 크면 오버드로/합성 비용 증가
  • Falloff Strength: 중심→테두리 감쇄 속도. 높을수록 가장자리로 빠르게 어두워짐

Blending & Overlap — 라이트 중첩 규칙

Blend Style (Renderer2DData에서 정의)

  1. Default: 일반적 합성—자연스러움
  2. Additive: 밝기/색을 덧셈 누적—네온/글로우
  3. Multiply with Mask (R): 마스크(R) 곱—검은 영역 약화
  4. Additive with Mask (R): 마스크(R) + 덧셈—국부 글로우·하이라이트


Overlap Operation

  • Additive: 겹칠수록 밝아짐—시선 유도/효과 강조
  • Alpha Blending: 알파 반영 부드러운 섞임—Light 순서 영향

Blend Style의 숨겨진 비용: 풀스크린 RT

Renderer2DData에 정의된 Blend Style 하나당 풀스크린 Render Texture(RT) 하나가 생성됩니다. 이것은 Light2D의 내부 구현상 피할 수 없는 비용입니다.

Blend Style 수추가 RT 수750×1334 기준 VRAM (RGBA32)비고
1개1장~4 MB최소 구성
2개2장~8 MB본 프로젝트 권장
4개 (전부 사용)4장~16 MB모바일에서 부담

권장: 모바일에서는 실제 사용하는 Blend Style만 활성화하세요. 본 프로젝트에서는 Default + Additive 2개만 사용하여 RT 비용을 최소화하고 있습니다. 사용하지 않는 Blend Style은 Renderer2DData에서 비활성화하면 RT가 생성되지 않습니다.


프로젝트 표준 라이트 구성 (3종 프리셋)

1) Ambient(Global) — 전역 분위기

  • Light Type: Global
  • Color/Intensity: 씬 톤 + Intensity ≈ 0.25
  • Blend Style: Default
  • Overlap: Additive

2) Main Light(Spot) — 카메라 뷰포트 중심 노출

  • Type: Spot
  • Radius: 0~50
  • Falloff Strength: 0 (뷰포트 강조)
  • Normal Maps: Quality=Accurate, Distance≈Radius
  • Blend/Overlap: Default / Additive

3) Character Spot(Spot) — 등불/휴대 광원

  • Type: Spot
  • Radius: 0~10
  • Falloff Strength: ≈0.5 (자연스러운 감쇄)
  • Intensity: Main보다 조금 높게
  • Normal Maps: Accurate, Distance≈Radius
  • Blend/Overlap: Default / Additive

Normal Maps (2D 표면에 라이팅 질감 부여)

  • 정의: RGB에 픽셀 단위 법선 벡터 저장 → 하이라이트/음영을 2D에 구현
  • Quality: Accurate (정확)/ Fast (가성비)
  • Distance: 라이트가 노멀에 영향 주는 최대 거리 → Radius와 유사/약간 크게
  • 예외: 바닥 구름(Floor) 라이트는 Normal Maps 비활성

데이터 파이프라인 & 씬 구조

  • 배치 위치: GameScene / Main Camera / Lights
  • 에셋 경로: Assets/ExternalAssets/TileMap/Light/Dusk.asset
  • 노출 항목: Color, Intensity, Enable Spot Light, LightShaft Enabled
  • 생성/복제: Create → Cocone → Light2DSetting 또는 Cmd/Ctrl + D
  • SRDebugger 연동: InGameSROptions의 “Scene Light” 카테고리를 통해 라이트 프리셋 전환(Day/Night/Dusk 등)과 다크모드 토글이 가능
  • 헤더 일본어 설명: 「色 / 明るさ(強度) / スポットライト有効 / サンシャフト有効 はランタイムで安全に変更できます。」

햇살(Light Shaft) 제작 가이드 (Sprite Light)

  • Light Type: Sprite 사용, 프리팹(LightShaft)으로 만들어 맵 오브젝트 배치
  • 리소스(임시)
    • Assets/ExternalAssets/TileMap/Sprite/LightShafts/gradient light.png
    • Sprite_SunSpots_2 (바닥 구름 그림자), shadow2 (햇살 패턴)
  • 자동화 난점: Sprite Light 수/패턴이 많아 수작업 배치가 현실적
  • 주의: Floor(바닥) 구름 라이트는 Normal Maps Off

Part 2: Parallax + Chunk + Light2D 시스템 아키텍처

앞서 Light2D의 기본 개념, 타입, Blending 규칙, 그리고 프로젝트 표준 프리셋까지 살펴봤습니다. 이제 핵심 질문에 답해봅시다: 수백 개의 Light2D 이펙트를 대규모 맵에서 어떻게 저비용으로 관리하는가?

게임 맵이 커지면 Light2D 이펙트도 함께 늘어납니다. 구름 그림자, 광선, 환경 조명 등을 맵 전체에 직접 배치하면 수백~수천 개의 Light2D가 동시에 렌더링됩니다. 모바일에서 이것은 곧 프레임 드롭을 의미합니다.

해결책은 “보이는 곳만 살려두고, 보이지 않는 곳은 치운다”입니다. 오픈 월드 게임이 청크(Chunk) 기반으로 지형을 로드/언로드하듯, Light2D 이펙트도 플레이어 주변의 청크만 활성화하고 멀어진 청크는 오브젝트 풀로 반납합니다.

핵심 아이디어

flowchart LR
    subgraph Player["플레이어 이동"]
        direction TB
        P1["현재 위치<br/>(X, Z)"]
        P2["청크 좌표 계산<br/>FloorToInt(pos / chunkSize)"]
        P1 --> P2
    end

    subgraph ChunkSystem["청크 시스템"]
        direction TB
        C1["이전 청크 목록"]
        C2["새 청크 목록<br/>(3×3 범위)"]
        C3["차집합 연산"]
        C1 --> C3
        C2 --> C3
    end

    subgraph Actions["실행"]
        direction TB
        A1["OnChunkEnter<br/>풀에서 꺼내 스폰"]
        A2["OnChunkExit<br/>풀로 반납"]
    end

    P2 --> C2
    C3 --> A1
    C3 --> A2
  • 맵을 일정 크기(chunkSize)의 격자로 분할
  • 플레이어 위치를 청크 좌표로 변환
  • 이전 프레임 청크 좌표와 비교하여 새로 진입한 청크에는 이펙트 스폰, 벗어난 청크는 반납
  • 모든 스폰/반납은 오브젝트 풀을 통해 Instantiate/Destroy 비용을 제거

전체 구성 요소

flowchart TB
    subgraph Manager["ParallaxSystemManager"]
        direction TB
        M1["타깃(플레이어) 추적"]
        M2["청크 경계 감지"]
        M3["전체 레이어 갱신"]
        M1 --> M2 --> M3
    end

    subgraph Handlers["레이어 핸들러"]
        direction LR
        H1["StaticEffectLayerHandler<br/>(정적 이펙트)"]
        H2["MovingEffectLayerHandler<br/>(이동 이펙트)"]
        H3["TileEffectLayerHandler<br/>(타일 배경)"]
    end

    subgraph Data["데이터 레이어"]
        direction LR
        D1["EffectLayerSetting<br/>(SO)"]
        D2["Light2DSetting<br/>(SO)"]
        D3["ParallaxLightRuntimeTag<br/>(런타임 캐시)"]
    end

    subgraph Pool["풀링 시스템"]
        direction LR
        P1["PrefabObjectPoolManager"]
        P2["InstanceObjectPool"]
        P3["PooledGameObject"]
    end

    M3 --> H1 & H2 & H3
    D1 --> H1 & H2
    D2 --> Manager
    H1 & H2 --> P1
    P1 --> P2 --> P3
    H1 & H2 --> D3
구성 요소역할핵심 책임
ParallaxSystemManager오케스트라 지휘자타깃 추적, 청크 경계 감지, 전체 레이어 갱신 트리거
IParallaxLayerHandler인터페이스핸들러 공통 계약 (Initialize/RefreshChunks/OnUpdate/CleanUp)
AbstractEffectLayerHandler기반 클래스청크 관리 공통 로직, Light2D 설정 전파
StaticEffectLayerHandler정적 이펙트고정 위치 이펙트 스폰, 거리 기반 카링
MovingEffectLayerHandler이동 이펙트연속 이동 + 경계 리사이클(무한 흐름 연출)
TileEffectLayerHandler타일 배경단순 타일 프리팹 배치 (풀링 미사용)
ParallaxLightRuntimeTag런타임 캐시Light2D 컴포넌트 캐시 + BaseScale 보존
EffectLayerSettingSO 설정프리팹, 샘플링 밀도, 이동 속도, 카링 거리
Light2DSettingSO 설정씬 라이트 + 이펙트 라이트 색상/강도/스케일
PrefabObjectPoolManager풀 팩토리프리팹별 풀 생성·관리, Hash 기반 O(1) 조회

💬 잠깐, 이건 알고 가자

Q. 왜 Light2D 이펙트에 청크 시스템이 필요한가요?

Light2D 하나하나는 가볍지만, 수백 개가 동시에 렌더링되면 합산 비용이 급증합니다. 특히 Overlap이 발생하면 같은 픽셀을 여러 번 합성하므로 오버드로가 심해집니다. 청크 시스템으로 플레이어 주변 30~50개만 활성화하면 이 비용을 통제할 수 있습니다.

Q. chunkSize는 어떻게 결정하나요?

카메라 뷰포트 크기를 기준으로 합니다. 본 프로젝트에서는 30~80 범위를 권장합니다. 너무 작으면 청크 전환이 잦아 오버헤드가 발생하고, 너무 크면 불필요한 이펙트가 많아집니다. activeChunkRange=1이면 3×3(9개) 청크가 활성화됩니다.


Handler 시스템: 이펙트 레이어의 두뇌

앞서 전체 구조를 조감도로 봤습니다. 이제 각 핸들러가 실제로 어떻게 동작하는지 들여다봅시다. 핸들러 시스템은 인터페이스 → 추상 클래스 → 구체 구현 3단 계층으로 구성됩니다. Unity의 MonoBehaviour 상속 구조와 유사하지만, 여기서는 순수 C# 클래스로 구현하여 GameObject 부착 없이 경량으로 동작합니다.

IParallaxLayerHandler 인터페이스

모든 핸들러가 구현해야 하는 계약입니다.

1
2
3
4
5
6
7
8
9
10
11
public interface IParallaxLayerHandler
{
    void Initialize(AbstractParallaxLayerSetting setting, Transform layerParent, Transform poolParent);
    void RefreshChunks(Vector3 targetPosition, bool isInitialLoad);
    void OnUpdate(Vector3 targetPosition);
    void CleanUp();
    void SetEnabled(bool enabled);
    void CollectRuntimeTags(List<ParallaxLightRuntimeTag> buffer);
    void SetLightSetting(Light2DSetting lightSetting);
    void UpdateCurrentLightSetting(Light2DSetting lightSetting);
}
메서드호출 시점역할
Initialize시스템 시작 시 1회풀 생성, 설정 참조 저장
RefreshChunks청크 경계 이동 시새 청크 스폰, 이탈 청크 처리
OnUpdate매 프레임거리 카링(Static), 이동+리사이클(Moving)
CleanUp씬 종료 시풀 정리, 메모리 해제
SetEnabled설정 변경 시내부 플래그 설정 (현재 가시성은 Light2DSetting의 Enabled 플래그로 제어)
SetLightSetting라이트 즉시 적용모든 활성 이펙트에 라이트 설정 반영
UpdateCurrentLightSetting트랜지션 중설정만 캐시 (이펙트는 별도 애니메이션)

AbstractEffectLayerHandler — 공통 기반

Static과 Moving 핸들러가 공유하는 청크 관리 로직입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected void RefreshChunks(Vector3 targetPosition, bool isInitialLoad)
{
    // 1. 현재 위치에서 필요한 청크 좌표 집합 계산 (3×3 범위)
    HashSet<Vector2Int> neededChunks = GetNeededChunks(centerChunk, setting.activeChunkRange);

    // 2. 이탈한 청크 먼저 처리 (ExceptWith로 O(n) 차집합)
    var chunksToRemove = new HashSet<Vector2Int>(activeChunks);
    chunksToRemove.ExceptWith(neededChunks);
    foreach (var chunk in chunksToRemove) OnChunkExit(chunk);

    // 3. 새로 진입한 청크: 이펙트 스폰
    var chunksToAdd = new HashSet<Vector2Int>(neededChunks);
    chunksToAdd.ExceptWith(activeChunks);
    foreach (var chunk in chunksToAdd) OnChunkEnter(chunk, targetPosition);

    activeChunks = neededChunks;
}

핵심 설계: OnChunkExit(이탈)을 OnChunkEnter(진입)보다 먼저 처리합니다. 이렇게 하면 풀에 반납된 오브젝트를 즉시 재활용할 수 있어 풀 크기를 절약합니다. OnChunkEnterOnChunkExit는 가상 메서드로, Static과 Moving이 각자의 전략으로 스폰/반납을 처리합니다.

청크 라이프사이클

stateDiagram-v2
    [*] --> Inactive: 맵 로드
    Inactive --> Active: OnChunkEnter<br/>(풀에서 프리팹 꺼냄)
    Active --> Active: OnUpdate 매 프레임<br/>(카링/이동 처리)
    Active --> Culled: 거리 초과<br/>(풀로 반납)
    Culled --> Active: 재진입 시<br/>(풀에서 다시 꺼냄)
    Active --> [*]: CleanUp<br/>(씬 종료)

💬 잠깐, 이건 알고 가자

Q. 왜 OnChunkExit에서 바로 이펙트를 제거하지 않나요?

플레이어가 청크 경계를 자주 왔다 갔다 하면 이펙트가 반복적으로 스폰/반납되어 깜빡임(flickering)이 발생합니다. 대신 거리 기반 카링으로 충분히 멀어진 이펙트만 제거합니다. 게임에서 LOD 전환 시 히스테리시스(hysteresis) 영역을 두는 것과 같은 원리입니다.

Q. Activator.CreateInstance로 핸들러를 생성하는 이유는요?

핸들러 타입이 EffectLayerSetting.GetHandlerType()에서 동적으로 결정되기 때문입니다. ScriptableObject의 설정에 따라 Static 또는 Moving 핸들러가 런타임에 선택됩니다. 새 핸들러 타입을 추가할 때 Manager 코드를 수정할 필요가 없어 OCP(Open-Closed Principle)를 지킵니다.


Static vs Moving: 두 가지 이펙트 전략

같은 AbstractEffectLayerHandler를 상속하지만, 런타임 동작은 완전히 다릅니다. 숲에 고정된 빛 줄기와 하늘을 떠다니는 구름 그림자를 떠올리면 직관적입니다.

StaticEffectLayerHandler — 고정 이펙트

스폰 전략: 청크 내 격자를 만들고, 밀도(density) 확률로 셀마다 이펙트를 배치합니다. 시드(seed) 기반 결정적 랜덤이므로 같은 청크는 항상 같은 배치가 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 청크 진입 시 이펙트 배치 (핵심 로직 발췌)
protected override void OnChunkEnter(Vector2Int chunkCoord, Vector3 targetPos)
{
    if (chunkEffects.ContainsKey(chunkCoord)) return;  // 중복 방지

    var effects = new List<GameObject>();
    var rng = new System.Random(HashChunkSeed(chunkCoord));  // 결정적 시드

    for (int gx = 0; gx < gridCount; gx++)
    for (int gz = 0; gz < gridCount; gz++)
    {
        if (rng.NextDouble() > density) continue;        // 확률 필터
        if (effects.Count >= maxEffectCount) break;       // 상한 제한

        Vector3 worldPos = ChunkToWorld(chunkCoord, gx, gz);
        GameObject go = pool.Get(randomPrefab, layerParent);
        go.transform.position = worldPos;

        // Light2D 설정 즉시 적용
        ApplyCurrentLightSetting(go);
        effects.Add(go);
    }

    chunkEffects[chunkCoord] = effects;
}

카링 전략: OnUpdate에서 매 프레임 거리 검사, maxDistance + chunkSize를 초과한 이펙트를 풀로 반납합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 매 프레임 거리 기반 카링 (발췌)
public override void OnUpdate(Vector3 targetPosition)
{
    float cullDistSq = (maxDistance + chunkSize) * (maxDistance + chunkSize);

    for (int i = allActiveEffects.Count - 1; i >= 0; i--)
    {
        float distSq = (allActiveEffects[i].transform.position - targetPosition).sqrMagnitude;
        if (distSq > cullDistSq)
        {
            allActiveEffects[i].GetComponent<PooledGameObject>().Release();
            allActiveEffects.RemoveAt(i);
        }
    }
}

설계 포인트: 거리 비교에 sqrMagnitude를 사용합니다. Vector3.Distance는 내부적으로 제곱근 연산(sqrt)이 들어가므로, 매 프레임 수십 개의 이펙트를 비교할 때 제곱 거리 비교가 더 효율적입니다.

MovingEffectLayerHandler — 이동 이펙트

핵심 차이: 이펙트가 moveDirection 방향으로 매 프레임 이동하며, 경계를 벗어나면 반대편으로 텔레포트하여 무한히 흐르는 듯한 연출을 만듭니다. 구름 그림자가 끊임없이 흘러가는 것처럼 보이는 비결입니다.

flowchart LR
    subgraph Movement["이동 이펙트 라이프사이클"]
        direction LR
        A["스폰 위치"] -->|"moveSpeed × deltaTime"| B["이동 중"]
        B -->|"경계 초과"| C["killDist 도달"]
        C -->|"반대편 텔레포트"| D["리사이클 위치"]
        D --> B
    end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 매 프레임 이동 + 경계 리사이클 (핵심 발췌)
public override void OnUpdate(Vector3 targetPosition)
{
    Vector3 moveStep = moveDirection.normalized * moveSpeed * Time.deltaTime;

    foreach (var go in activeMovingEffects)
    {
        go.transform.position += moveStep;

        // 경계 초과 시 반대편으로 텔레포트
        if (IsOutOfBounds(go.transform.position, targetPosition, killDist))
        {
            go.transform.position = RecyclePosition(go.transform.position, targetPosition);
        }
    }
}

오버랩 방지: Moving 이펙트는 스폰 시 minSeparation 거리를 체크합니다. 기존 이펙트와 겹치면 최대 rejectionTry회까지 대안 위치를 탐색하지만, 모든 재시도가 실패하면 해당 위치를 스킵(스폰하지 않음)합니다. 재시도 성공 시에도 현재 구현에서는 원래 격자 위치에 스폰됩니다(설계 의도: 결정적 배치 보존).

가시성 제어 방식 비교

Static과 Moving은 이펙트를 “끄는” 방식이 다릅니다. 이 차이는 의도적인 설계입니다.

특성StaticMoving
끄는 방법Light2D.intensity = 0fLight2D.enabled = false
GameObjectActive 유지Active 유지
이유Intensity 0이면 렌더 비용 최소, 페이드 연출 가능이동 애니메이션이 계속 돌아가므로 Light만 완전히 숨김
복구 비용Intensity 값만 복원 (즉시)enabled = true (즉시)
스케일 처리baseScale × StaticEffectScalebaseScale × MovingEffectScale

💬 잠깐, 이건 알고 가자

Q. 왜 GameObject를 비활성화하지 않고 Light2D만 끄나요?

SetActive(false)를 하면 다시 켤 때 Unity 내부에서 OnEnable 콜백 재실행, 물리/렌더 재등록 등 비용이 발생합니다. (참고: Awake는 오브젝트 라이프타임 중 한 번만 호출되므로 재활성화 시에는 실행되지 않습니다.) 반면 Light2D의 intensity나 enabled 토글은 렌더 파이프라인 레벨에서만 동작하여 훨씬 가볍습니다. 수십 개의 이펙트를 매 프레임 토글해야 하는 상황에서 이 차이는 체감됩니다.

Q. Static이 intensity = 0으로 끄는 이유는요?

트랜지션 연출 때문입니다. 예를 들어 낮→밤 전환 시 Static 이펙트의 intensity를 DOTween으로 0→1까지 서서히 올리면 자연스러운 페이드인이 됩니다. enabled의 true/false는 부드러운 보간이 불가능합니다.


오브젝트 풀링 & ParallaxLightRuntimeTag

청크 시스템의 성능은 결국 오브젝트 풀링에 달려 있습니다. 플레이어가 이동할 때마다 이펙트를 Instantiate/Destroy 한다면, GC 스파이크로 프레임이 튑니다. 본 시스템은 3계층 풀링 아키텍처를 사용합니다.

풀링 아키텍처

flowchart TB
    subgraph Factory["PrefabObjectPoolManager (팩토리)"]
        direction TB
        F1["Dictionary&lt;int, InstanceObjectPool&gt;"]
        F2["프리팹 Hash → 풀 매핑"]
        F1 --> F2
    end

    subgraph Pool["InstanceObjectPool (단일 프리팹 풀)"]
        direction TB
        P1["Unity ObjectPool&lt;PooledGameObject&gt;"]
        P2["maxPoolSize = 1024"]
        P3["activeObjects 추적"]
        P1 --> P2
        P1 --> P3
    end

    subgraph Component["PooledGameObject (컴포넌트)"]
        direction TB
        C1["Release() 호출 시 풀로 반납"]
        C2["풀 내부: SetActive(false)로 비활성화"]
        C3["위치를 (10000, 10000, 10000)으로 이동"]
        C1 --> C2 --> C3
    end

    Factory --> Pool --> Component
계층클래스역할
팩토리PrefabObjectPoolManager여러 프리팹의 풀을 관리. Hash 기반 O(1) 풀 조회
InstanceObjectPool단일 프리팹의 풀. Unity ObjectPool<T> 래핑
컴포넌트PooledGameObject프리팹에 부착. Release() 호출 시 자동 반납

PooledGameObject 라이프사이클

stateDiagram-v2
    [*] --> Pool: 최초 생성 (Instantiate)
    Pool --> Active: Get() 호출
    Active --> Active: 게임 중 사용
    Active --> Teleport: Release() 호출
    Teleport --> Pool: 풀 반납(비활성화) 후 (10000,10000,10000)으로 이동
    Pool --> Active: 재사용 Get()
    Pool --> [*]: CleanUp (Destroy)

반납 순서 설명: 현재 구현에서 Release()를 호출하면, 먼저 pool.Release(this)로 풀에 반납되어 즉시 SetActive(false)로 비활성화된 뒤, 위치를 (10000, 10000, 10000)으로 이동시킵니다. 비활성화 후 위치 이동하는 이유는, 다음에 풀에서 다시 꺼낼 때(Get()) SetActive(true) 되는 순간 이전 위치에서 한 프레임 깜빡임이 발생하는 것을 방지하기 위해서입니다.

ParallaxLightRuntimeTag — Light2D 캐시의 핵심

이 컴포넌트는 스폰된 이펙트 프리팹에 부착되어 두 가지 핵심 역할을 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ParallaxLightRuntimeTag : MonoBehaviour, IPooledObjectInitializer
{
    [SerializeField] private Vector3 baseScale = Vector3.zero;
    [SerializeField] private Light2D[] cachedLight;
    [SerializeField] private ParallaxEffectType kind = ParallaxEffectType.Static;

    public Vector3 BaseScale => baseScale;               // 원본 스케일 보존
    public Light2D[] CachedLight => cachedLight;          // Light2D 캐시
    public ParallaxEffectType Type { get => kind; set => kind = value; }

    public void EnsureCache(bool forceRefresh = false)
    {
        if (cachedLight == null || forceRefresh)
            cachedLight = GetComponentsInChildren<Light2D>();

        if (baseScale == Vector3.zero || forceRefresh)
            baseScale = transform.localScale;
    }

    // 풀에서 재사용될 때 호출
    public void OnGetInstance(bool reused)
    {
        if (reused) baseScale = transform.localScale;  // 풀링이 스케일을 리셋할 수 있으므로
    }
}

역할 1: Light2D 컴포넌트 캐시

GetComponentsInChildren<Light2D>는 비용이 있는 호출입니다. 이펙트가 스폰될 때 한 번만 호출하여 결과를 캐시하고, 이후 Light 설정 변경 시 캐시된 배열을 순회합니다. 수십 개의 이펙트가 동시에 Light 설정을 변경할 때 이 캐시가 없다면 프레임 드롭이 체감됩니다.

역할 2: BaseScale 보존

스케일 관련 미묘한 버그를 방지합니다. Light2D 설정을 적용할 때 localScale = baseScale × scaleMultiplier로 계산하는데, baseScale을 보존하지 않으면 적용할 때마다 스케일이 중첩되어 점점 커지거나 작아지는 문제가 생깁니다.

1
2
3
4
5
6
7
8
9
// 문제 시나리오 (BaseScale 미보존)
원본 스케일: (1, 1, 1)
1차 적용: (1, 1, 1) × 1.5 = (1.5, 1.5, 1.5)
2차 적용: (1.5, 1.5, 1.5) × 1.5 = (2.25, 2.25, 2.25)  ← 의도하지 않은 누적!

// 해결 (BaseScale 보존)
BaseScale: (1, 1, 1)  ← 항상 이 값 기준
1차 적용: (1, 1, 1) × 1.5 = (1.5, 1.5, 1.5)
2차 적용: (1, 1, 1) × 1.5 = (1.5, 1.5, 1.5)  ← 정확!

Light2D 설정 파이프라인

이펙트의 시각적 결과물은 결국 두 개의 ScriptableObject에 의해 결정됩니다: Light2DSettingEffectLayerSetting. 이 둘이 어떻게 협력하는지 이해하면 전체 시스템의 데이터 흐름이 명확해집니다.

Light2DSetting (ScriptableObject)

씬 전체의 라이팅 톤을 정의합니다. 하나의 Light2DSetting이 “낮”, “밤”, “석양” 같은 라이팅 프리셋 하나에 대응합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[CreateAssetMenu(menuName = "Cocone/Light2DSetting")]
public class Light2DSetting : ScriptableObject
{
    [Header("씬 라이트")]
    public Color AmbientColor;
    public float AmbientIntensity;
    public Color MainColor;
    public float MainIntensity;
    public Color SpotColor;
    public float SpotIntensity;
    public bool EnableSpotLight;
    public bool LightShaftEnabled;

    [Header("Parallax 이펙트 — Static")]
    public bool StaticEffectsEnabled;
    public Color StaticEffectColor;
    public float StaticEffectIntensity;
    public float StaticEffectScale;

    [Header("Parallax 이펙트 — Moving")]
    public bool MovingEffectsEnabled;
    public Color MovingEffectColor;
    public float MovingEffectIntensity;
    public float MovingEffectScale;

    // Odin Inspector: 에디터에서 값 변경 시 즉시 브로드캐스트
    [OnValueChanged(nameof(NotifyChange))]
    public static event Action<Light2DSetting> OnSettingChanged;
}
카테고리프로퍼티설명
씬 라이트AmbientColor/IntensityGlobal Light의 색/강도
 MainColor/IntensityMain Spot Light의 색/강도
 SpotColor/IntensityCharacter Spot Light의 색/강도
 EnableSpotLightSpot Light 활성화 여부
 LightShaftEnabled햇살 Sprite Light 활성화 여부
Static 이펙트StaticEffectColor/Intensity/Scale정적 이펙트의 색·강도·스케일
Moving 이펙트MovingEffectColor/Intensity/Scale이동 이펙트의 색·강도·스케일

EffectLayerSetting (ScriptableObject)

개별 이펙트 레이어의 스폰 규칙과 동작 파라미터를 정의합니다.

프로퍼티범위설명
effectPrefabs1개 이상스폰할 프리팹 목록 (랜덤 선택)
chunkSize30~80청크 한 변의 크기
activeChunkRange0~2활성 청크 반경 (1 → 3×3)
gridCount4~50청크 내 격자 분할 수
density0.1~0.25셀당 스폰 확률
maxDistance30+카링 거리
isMovingEffectboolStatic/Moving 핸들러 선택
moveDirectionVector2이동 방향 (Moving 전용)
moveSpeed0.5~1.0이동 속도 (Moving 전용)
minSeparationfloat최소 이격 거리 (Moving 전용)
rejectionTry1~8오버랩 방지 재시도 횟수
densityWeightenum밀도 가중치 (0.25x ~ 2.0x)

즉시 적용 vs 트랜지션

Light2D 설정을 바꾸는 방법은 두 가지입니다. 상황에 따라 선택합니다.

flowchart LR
    subgraph Immediate["즉시 적용"]
        direction TB
        I1["SetSceneLightSetting()"]
        I2["parallaxSystem.ApplyLightSetting()"]
        I3["모든 핸들러 → SetLightSetting()"]
        I4["모든 활성 이펙트 즉시 갱신"]
        I1 --> I2 --> I3 --> I4
    end

    subgraph Transition["트랜지션 적용"]
        direction TB
        T1["TransitionLightColors(setting, duration)"]
        T2["parallaxSystem.UpdateCurrentLightSetting()"]
        T3["DOTween으로 보간"]
        T4["씬 라이트: Color/Intensity 보간"]
        T5["이펙트 태그: Color/Intensity/Scale 보간"]
        T1 --> T2
        T1 --> T3
        T3 --> T4
        T3 --> T5
    end
적용 방식메서드효과사용 시점
즉시ApplyLightSetting()모든 이펙트 즉시 갱신씬 진입, 설정 초기화
트랜지션TransitionLightColors()DOTween 보간 (부드러운 전환)낮→밤, 날씨 변화

💬 잠깐, 이건 알고 가자

Q. 트랜지션 중 새로 스폰되는 이펙트는 어떤 설정을 받나요?

UpdateCurrentLightSetting()이 미리 목표 설정을 캐시해두므로, 트랜지션 도중에 새로 스폰되는 이펙트는 목표 설정으로 바로 생성됩니다. 이미 존재하는 이펙트만 DOTween으로 보간되고, 새 이펙트는 최종 상태로 바로 나타나 위화감이 없습니다.

Q. DarkMode는 뭔가요?

보스 등장 등 연출용 기능입니다. HSV 색공간에서 Brightness만 낮춰 화면 전체를 어둡게 합니다. Hue/Saturation은 유지되므로 색감은 보존하면서 어두운 분위기만 연출됩니다. EnterDarkMode(duration) / ExitDarkMode(duration)으로 제어합니다.


SceneLightingController: 씬 라이팅의 지휘자

지금까지 살펴본 모든 조각들(씬 라이트, Parallax 이펙트, 트랜지션)을 하나로 통합하는 컨트롤러입니다. 게임씬에 배치되어 Light2DSetting을 받아 씬 전체에 반영합니다.

flowchart TB
    subgraph Controller["SceneLightingController"]
        direction TB
        SC1["Light2D 참조: Ambient, Main, Spot"]
        SC2["ParallaxSystemManager 참조"]
        SC3["ParallaxLightRuntimeTag 캐시"]
    end

    subgraph Methods["주요 메서드"]
        direction TB
        M1["SetSceneLightSetting()<br/>즉시 적용"]
        M2["TransitionLightColors()<br/>부드러운 전환"]
        M3["EnterDarkMode() / ExitDarkMode()<br/>연출용 암전"]
    end

    subgraph Targets["적용 대상"]
        direction LR
        T1["Ambient Light2D"]
        T2["Main Light2D"]
        T3["Spot Light2D"]
        T4["Static 이펙트들"]
        T5["Moving 이펙트들"]
    end

    Controller --> Methods
    Methods --> Targets

트랜지션 시스템 상세

낮→밤 전환 같은 연출에서는 갑작스러운 라이트 변경이 부자연스럽습니다. TransitionLightColors는 DOTween을 활용하여 모든 요소를 동시에 부드럽게 전환합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void TransitionLightColors(Light2DSetting newSetting, float duration)
{
    // 1. Parallax 시스템에 목표 설정 캐시 (새 스폰 이펙트용)
    parallaxSystem?.UpdateCurrentLightSetting(newSetting);

    // 2. 씬 라이트 트랜지션 (DOTween)
    DOTween.To(() => ambientLight.color, x => ambientLight.color = x,
               newSetting.AmbientColor, duration);
    DOTween.To(() => ambientLight.intensity, x => ambientLight.intensity = x,
               newSetting.AmbientIntensity, duration);
    // ... Main, Spot도 동일 패턴

    // 3. 이펙트 태그 트랜지션
    CacheEffectTags();  // 현재 활성 이펙트 수집
    foreach (var tag in cachedTags)
    {
        foreach (var light in tag.CachedLight)
        {
            var targetColor = tag.Type == ParallaxEffectType.Static
                ? newSetting.StaticEffectColor
                : newSetting.MovingEffectColor;
            DOTween.To(() => light.color, x => light.color = x, targetColor, duration);
            // ... intensity, scale도 동일 패턴
        }
    }
}

설계 포인트 1 — 라이프사이클: 씬이 파괴될 때 진행 중인 DOTween이 이미 파괴된 오브젝트에 접근하면 크래시가 발생합니다. OnDestroy에서 반드시 DOTween.Kill()로 진행 중인 트윈을 정리합니다.

설계 포인트 2 — GC 비용: DOTween.To(() => light.color, x => light.color = x, ...) 패턴은 호출마다 클로저 캡처 + 델리게이트 할당이 발생합니다. 수십 개의 이펙트에 동시 트랜지션을 걸면 GC 압력이 생깁니다. 본 프로젝트에서는 트랜지션 빈도가 낮아(씬 전환, 보스 연출 등) 허용 범위이지만, 매 프레임 호출되는 경로에서는 수동 Lerp로 GC-free 구현을 고려해야 합니다.


에디터 툴링 & 런타임 디버그

아무리 잘 만든 시스템도 튜닝할 수 없으면 무용지물입니다. 본 시스템은 에디터와 런타임 양쪽 모두에서 실시간 튜닝이 가능하도록 설계했습니다.

Light2DSettingWizard — 에디터 통합 도구

Tools > Light&Effect Setting 메뉴에서 접근하는 커스텀 에디터 윈도우입니다.

기능 요약:

기능
Light SettingsLight2DSetting SO의 씬 라이트 파라미터 편집
Static EffectsStatic EffectLayerSetting 인라인 편집, 프리팹 목록 관리
Moving EffectsMoving EffectLayerSetting 인라인 편집, 이동 방향/속도 조정

주요 기능:

  • 에셋 생성: 새 Light2DSetting/EffectLayerSetting을 메뉴에서 바로 생성
  • Used By 검색: 선택한 설정을 참조하는 에셋 목록을 Lazy 로드로 표시
  • 일괄 적용: 에디터에서 설정 변경 시 씬의 모든 관련 오브젝트에 즉시 반영
  • Odin Inspector 연동: EffectLayerSetting의 인라인 에디터로 직관적 편집

ParallaxDebugPanel — 런타임 디버그

게임 플레이 중 이펙트 파라미터를 실시간으로 조정할 수 있는 인게임 패널입니다.

기능:

  • Static/Moving EffectLayerSetting의 모든 파라미터를 슬라이더/입력 필드로 조정
  • Reset Defaults 버튼: Awake 시점의 원본 값으로 복원
  • Gizmo 토글: 청크 경계와 카링 범위를 씬 뷰에 시각화
  • 변경 사항은 EffectLayerSetting.Changed 이벤트를 통해 핸들러에 즉시 전파

SRDebugger 연동

SRDebugger의 Options Panel에는 InGameSROptions 컨테이너가 등록됩니다. “10) Scene Light” 카테고리에서 씬 라이트 프리셋(Day/Night/Dusk 등)을 전환하거나 다크모드를 토글할 수 있습니다. 프리셋 전환 시 DebugCommandMessage를 통해 SceneLightingController가 해당 Light2DSetting을 적용하고, 변경 사항은 OnSettingChanged 이벤트를 통해 시스템 전체에 브로드캐스트됩니다.

참고: 현재 구현에서는 Light2DSetting의 개별 파라미터(색상/강도/스케일)를 SRDebugger에서 직접 조정하는 기능은 제공되지 않습니다. 개별 튜닝이 필요한 경우 ParallaxDebugPanel 또는 에디터의 Light2DSettingWizard를 사용하세요.

flowchart LR
    subgraph Editor["에디터 단계"]
        E1["Light2DSettingWizard<br/>(커스텀 에디터 윈도우)"]
        E2["Inspector<br/>(Odin Inspector)"]
    end

    subgraph Runtime["런타임 단계"]
        R1["SRDebugger<br/>(트리플 탭)"]
        R2["ParallaxDebugPanel<br/>(인게임 UI)"]
    end

    subgraph System["시스템 반영"]
        S1["OnSettingChanged 이벤트"]
        S2["Changed 이벤트"]
        S3["모든 활성 이펙트 갱신"]
    end

    E1 --> S1
    E2 --> S1
    R1 --> S1
    R2 --> S2
    S1 --> S3
    S2 --> S3

성능 최적화 전략 총정리

지금까지 설명한 최적화 기법들을 한 표로 정리합니다.

최적화 기법적용 위치효과비고
청크 기반 로드/언로드ParallaxSystemManager전체 맵 대비 10~20%만 활성화chunkSize, activeChunkRange로 조절
오브젝트 풀링PrefabObjectPoolManagerInstantiate/Destroy 제거 → GC 스파이크 방지maxPoolSize = 1024
거리 기반 카링StaticEffectLayerHandler원거리 이펙트 자동 반납sqrMagnitude로 sqrt 비용 제거
경계 리사이클MovingEffectLayerHandler텔레포트로 무한 흐름 연출, 추가 스폰 불필요killDist 기준
Light2D 캐시ParallaxLightRuntimeTagGetComponentsInChildren 1회만 호출EnsureCache로 관리
BaseScale 보존ParallaxLightRuntimeTag스케일 누적 버그 방지풀 재사용 시 자동 갱신
Intensity 제어Static 가시성SetActive 대신 렌더 레벨 제어페이드 연출 겸용
시드 기반 결정적 랜덤OnChunkEnter같은 청크 = 같은 배치 → 일관성stageSeed로 스테이지별 변형
오버랩 방지Moving 스폰minSeparation + rejectionTry로 겹침 제거최대 8회 재시도
비활성화 후 텔레포트PooledGameObject.Release재활성화 시 1프레임 깜빡임 방지풀 반납(비활성화) 후 (10000,10000,10000)으로 이동
DOTween KillSceneLightingController씬 파괴 시 크래시 방지OnDestroy에서 정리

마무리

이 시스템의 핵심 설계 원칙을 정리하면 다음과 같습니다.

1. 보이는 것만 관리한다 — 청크 시스템으로 플레이어 주변만 활성화하여 렌더링 비용을 통제합니다.

2. 생성/파괴를 하지 않는다 — 오브젝트 풀로 모든 스폰/반납을 처리하여 GC 스파이크를 제거합니다.

3. 데이터 주도로 동작한다 — ScriptableObject(Light2DSetting, EffectLayerSetting)에 모든 설정이 외부화되어, 코드 수정 없이 디자이너가 튜닝할 수 있습니다.

4. 런타임에서 검증한다 — SRDebugger와 ParallaxDebugPanel으로 게임 플레이 중 실시간 조정이 가능하여, 빌드→테스트 반복 시간을 최소화합니다.

3D 씬에서 Light2D를 사용하는 것은 비정통적인 접근이지만, 청크 기반 Parallax + 오브젝트 풀과 결합하면 실시간 Directional Light + 실시간 섀도우 대비 렌더링 비용을 절감하면서도 충분한 시각 품질을 달성할 수 있었습니다. 다만, 이 비교에서 주의할 점이 있습니다:

비교 대상특성Light2D 대비
Realtime Directional + Shadow매 프레임 그림자 연산, 캐스케이드 SMLight2D가 확실히 저비용
Baked Light Probe베이크 결과를 SH 계수로 읽기, 런타임 비용 ≈ 0Light2D가 오히려 비용이 더 높을 수 있음
Baked Lightmap정적 오브젝트 전용, 메모리 비용 높지만 GPU 비용 ≈ 0상황에 따라 다름

정리: Light2D 청크 시스템이 유리한 시나리오는 (1) 실시간 라이팅이 필요하고 (2) 탑다운/고정 시점이며 (3) 2D 합성으로 충분한 시각 품질을 얻을 수 있는 경우입니다. “무조건 저비용”이 아니라, 프로젝트의 전제 조건에 맞출 때 최적의 트레이드오프를 달성합니다.


한계와 제약 사항

어떤 기술이든 한계를 정확히 아는 것이 올바른 의사결정의 시작입니다.

렌더링 제약

제약설명대안
2D Renderer 필수Light2D는 Renderer2D에서만 동작. Universal Renderer와 혼용 불가카메라 스택으로 분리하거나 프로젝트 전체를 2D Renderer로 통일
3D 라이팅 부재Lambertian/PBR 라이팅 없음. 메시의 음영이 평평함탑다운/2.5D 시점에서는 문제없으나 자유시점에서는 부적합
그림자 캐스팅 제한Light2D의 Shadow Caster는 2D 전용. 3D 메시 그림자 불가Sprite Mask, 수동 그림자 스프라이트로 보완
Sorting 기반 깊이Z-buffer 대신 Sorting Layer/Order로 깊이 결정3D 메시의 Sorting 설정을 수동 관리해야 함

성능 제약

제약설명완화 방법
Blend Style당 풀스크린 RT사용하는 Blend Style 수만큼 VRAM 소비2개 이하로 제한
오버드로Light2D 겹침 = 동일 픽셀 다중 합성청크 시스템으로 동시 활성 수 제한
배치 브레이킹Target Sorting Layers가 다른 Light2D는 배치 분리동일 Target 설정으로 통일
DOTween 클로저 할당트랜지션 시 GC 압력빈도가 낮은 경로에서만 사용

운영 제약

제약설명완화 방법
SRDebugger프리셋 전환만 가능, 개별 파라미터 튜닝 불가ParallaxDebugPanel 또는 에디터 Wizard 사용
SetEnabled 플래그현재 forceDisabled 플래그 미참조 (dead code)향후 구현 예정 또는 제거 검토
Moving 오버랩 방지재시도 성공해도 원래 격자 위치에 스폰의도적 동작이 아니라면 코드 수정 필요

💬 잠깐, 이건 알고 가자

Q. 확장하려면 어떻게 하나요?

새로운 이펙트 유형이 필요하면: (1) AbstractParallaxLayerSetting을 상속한 SO 생성, (2) AbstractEffectLayerHandler를 상속한 핸들러 구현, (3) SO의 GetHandlerType()에서 새 핸들러 타입 반환. Manager 코드는 수정할 필요가 없습니다. 실제로 TileEffectLayerHandler가 이 방식으로 추가되었습니다.

Q. 이 기법을 자유시점 3D 게임에 적용할 수 있나요?

권장하지 않습니다. 자유시점에서는 3D 메시의 음영이 평평하게 보이는 문제, Z-buffer 대신 Sorting 기반 깊이 처리의 한계, 카메라 회전 시 2D 합성 결과가 부자연스러워지는 문제 등이 발생합니다. 탑다운, 사이드뷰, 고정 카메라 프로젝트에서 가장 효과적입니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.