1 of 79

Day 3: GPU Instancing & Real-time Monitoring

DrawMeshInstancedIndirect | Per-Instance Heatmap | GPU Picking

ComputeShader Heat Propagation | Smart Engine Room Dashboard

Unity 6 (6000.3.8f1) + URP 17.3.0

2 of 79

학습 목표

Day 3 — GPU Instancing과 실시간 모니터링 시스템

1. DrawMeshInstancedIndirect로 수만 개 오브젝트를 Draw Call 1~2로 렌더링

2. SV_InstanceID + StructuredBuffer로 인스턴스별 고유 데이터 전달

3. GPU Color Picking — Collider 없이 인스턴스 선택

4. ComputeShader 열 전파 — Day 2 Wake 수학 재활용

5. 통합 Dashboard — Digital Twin 관제 시스템 프로토타입

Day 2에서 배운 패턴 (StructuredBuffer, ComputeShader, 2-Pass, Double Buffering)

이 모두 Day 3에서 다른 형태로 재등장합니다.

3 of 79

미니 프로젝트: Smart Engine Room Dashboard

시나리오:

거대한 선박 엔진룸에 10,000개의 파이프가 설치되어 있다.

각 파이프에는 온도/압력/진동 센서가 장착되어 있고,

관제사가 실시간으로 파이프 상태를 모니터링한다.

요구사항:

① 10,000개 파이프를 끊김 없이 렌더링

② 각 파이프의 센서 데이터를 색상으로 실시간 표시

③ 파이프를 클릭하면 상세 정보 표시

④ 이상 감지 시 카메라 자동 포커스 + 경고 UI

⑤ 클릭한 파이프에서 열이 주변으로 전파되는 시뮬레이션

5개 기술 = 5개 데모 씬 (Step 06 → 10), 마지막에 통합

4 of 79

5단계 데모 구조

Step 06: GPU Instancing 기초

Mesh 1개 + Matrix 10,000개 → Draw Call 1~2개

Step 07: Per-Instance 히트맵

StructuredBuffer<float> 추가 → 인스턴스별 색상

Step 08: GPU Color Picking

Collider 없이 클릭 → 인스턴스 식별

Step 09: ComputeShader 열 전파

Day 2 Wake 패턴 재활용 → 열이 이웃으로 퍼짐

Step 10: 통합 Dashboard

모든 기능을 합쳐 Digital Twin 관제 시스템

각 단계에서 1~2개씩 StructuredBuffer가 추가되는 구조

→ 점진적 학습 (누적형 데모)

5 of 79

전체 아키텍처 — 데이터 흐름

[CPU 영역] [GPU 영역]

PipeInstancer PipeInstanced_URP.shader

├─ matrices[] ──ComputeBuffer──────→ ├─ _InstanceMatrices[SV_InstanceID]

├─ argsBuffer ──IndirectArguments──→ └─ 하나의 Draw Call로 10,000개 렌더링

└─ DrawMeshInstancedIndirect() │

SensorSimulator HeatmapInstanced_URP.shader

├─ sensorValues[] ──ComputeBuffer──→ ├─ _SensorData[SV_InstanceID]

├─ Perlin Noise 기반 정상값 ├─ 3단계 그라디언트 (Cool → Warn → Hot)

└─ 이상 발생 시 값 상승 ├─ Alert emission pulse (sin 깜빡임)

└─ Selection highlight (Fresnel outline)

GPUPicker InstancePicking.shader

├─ CommandBuffer → pickingRT ────→ ├─ instanceID+1 → RGB 3채널 인코딩

└─ ReadPixels() (클릭 좌표 1px) └─ 24비트 = 최대 16,777,215개 식별

HeatPropagationManager HeatPropagation.compute

├─ Double Buffer (A↔B) ──────→ ├─ CSHeatInject (열 주입)

└─ Dispatch() 25Hz ──────────→ └─ CSHeatUpdate (감쇠 + 이웃 확산)

Day 2 Wake와 동일 패턴: 2-Pass 설계 + Double Buffering + decay/diffusion 수학

6 of 79

Day 2 → Day 3 기술 대응표

╔══════════════════════════════════╦══════════════════════════════════╦════════════════════════════╗

║ Day 2 (Ocean + Wake) ║ Day 3 (Engine Room) ║ 공통 패턴 ║

╠══════════════════════════════════╬══════════════════════════════════╬════════════════════════════╣

║ StructuredBuffer<float3> ║ StructuredBuffer<float4x4> ║ GPU 버퍼로 대량 데이터 전달 ║

║ _Vertices ║ _InstanceMatrices ║ ║

╠══════════════════════════════════╬══════════════════════════════════╬════════════════════════════╣

║ SV_VertexID ║ SV_InstanceID ║ GPU 자동 부여 고유 인덱스 ║

╠══════════════════════════════════╬══════════════════════════════════╬════════════════════════════╣

║ RWTexture2D<float> _WakeRT ║ RWStructuredBuffer<float> ║ Compute 결과 저장소 ║

║ (2D 이미지) ║ _HeatBuffer (1D 배열) ║ ║

╠══════════════════════════════════╬══════════════════════════════════╬════════════════════════════╣

║ CSWakeStamp + CSWakeUpdate ║ CSHeatInject + CSHeatUpdate ║ 2-Pass (주입 + 확산) ║

╠══════════════════════════════════╬══════════════════════════════════╬════════════════════════════╣

║ Double Buffering (WakeRT A↔B) ║ Double Buffering (HeatBuf A↔B) ║ Read/Write 충돌 방지 ║

╠══════════════════════════════════╬══════════════════════════════════╬════════════════════════════╣

║ mat.SetTexture("_WakeTex") ║ mat.SetBuffer("_SensorData") ║ Compute → Shader 공유 ║

╚══════════════════════════════════╩══════════════════════════════════╩════════════════════════════╝

Day 2 패턴이 Day 3에서 그대로 재활용됩니다. 데이터 구조만 2D Texture → 1D Buffer로 변경.

7 of 79

오전 세션 로드맵

Step 06: GPU Instancing 기초

• DrawMeshInstancedIndirect API

• IndirectArguments 버퍼

• SV_InstanceID + StructuredBuffer<float4x4>

Step 07: Per-Instance 히트맵

• StructuredBuffer<float> 센서 데이터

• 3색 그라디언트 셰이더 (Cool/Warn/Hot)

• Alert Emission + Selection Highlight

Step 08: GPU Color Picking

• CommandBuffer로 ID 렌더링

• RGB 24비트 인코딩 / 디코딩

• Linear 색 공간 함정

Step 09~10은 오후 세션에서 진행

8 of 79

핵심 용어 정리

GPU Instancing:

하나의 메쉬를 여러 인스턴스로 복제하여 한 번의 Draw Call로 렌더링

Indirect Arguments:

GPU가 직접 읽는 5개 uint 값 (인스턴스 수 포함)

SV_InstanceID:

GPU가 각 인스턴스에 자동 부여하는 고유 정수 인덱스

StructuredBuffer:

GPU에서 읽기 가능한 타입 안전 배열 (C#의 ComputeBuffer에 대응)

RWStructuredBuffer:

GPU에서 읽기+쓰기 가능한 배열 (ComputeShader 전용)

CommandBuffer:

GPU 렌더링 명령을 기록/실행하는 큐 (커스텀 렌더링용)

Double Buffering:

읽기/쓰기 버퍼를 분리하여 동시성 문제 방지 (Ping-Pong 패턴)

9 of 79

1

GPU Instancing

10,000개를 Draw Call 1~2로 렌더링하는 방법

10 of 79

실무에서 왜 GPU Instancing이 필요한가?

Digital Twin 현실:

• 선박 엔진룸: 파이프 5,000~20,000개

• 공장 설비: 밸브, 모터, 센서 수천 개

• 건축 BIM: 볼트, 너트, 클램프 수만 개

공통점:

같은 형태의 오브젝트가 수천~수만 개 반복!

→ 각각 다른 위치, 같은 메쉬

GPU Instancing이 없으면:

오브젝트 하나하나가 Draw Call 1개

CPU가 10,000번 GPU에 명령 전달

→ CPU 병목으로 15 FPS 이하

GPU Instancing 적용 후:

Draw Call 1~2개로 10,000개 렌더링 → 250+ FPS

11 of 79

렌더링 방식 비교 — 세 가지 접근법

방식 1: GameObject 각각 생성

• 10,000 오브젝트 → ~10,000 Draw Calls

• CPU 시간: 30ms+ → 프레임 드랍

방식 2: GPU Instancing (MaterialPropertyBlock)

• 배치 최적화 → ~10~50 Draw Calls

• CPU: 5ms+ (여전히 CPU 부담)

방식 3: DrawMeshInstancedIndirect ★

• C# 한 줄 호출 → 1~2 Draw Calls

• CPU: <1ms → GPU가 전부 처리

핵심: CPU는 '그려라' 명령 1번, 나머지는 전부 GPU가 처리

12 of 79

DrawMeshInstancedIndirect란?

Unity API: Graphics.DrawMeshInstancedIndirect(mesh, submesh, material, bounds, argsBuffer)

'Indirect'의 의미:

CPU가 인스턴스 수를 직접 전달하지 않음

GPU가 argsBuffer에서 인스턴스 수를 직접 읽음

→ GPU-driven 렌더링의 기본

장점:

1. CPU→GPU 전송 최소화 (args 20 byte만)

2. GPU Culling과 조합 가능 (인스턴스 수 동적 변경)

3. 오브젝트별 Transform + 데이터를 StructuredBuffer로 전달

제약:

1. 동일 Mesh + 동일 Material (셰이더 Variant 가능)

2. 개별 Collider/Rigidbody 없음 → Raycast 불가 (Step 08에서 해결)

3. Frustum Culling이 Bounds 단위 → 전체 on/off

13 of 79

IndirectArguments 버퍼 — GPU가 읽는 명령서

// PipeInstancer.cs (107~117줄)

argsBuffer = new ComputeBuffer(

1, // 원소 1개

5 * sizeof(uint), // 20 바이트

ComputeBufferType.IndirectArguments

);

uint[] args = new uint[5]

{

mesh.GetIndexCount(0), // [0] 인덱스 수

(uint)instanceCount, // [1] 인스턴스 수 ★

mesh.GetIndexStart(0), // [2] 시작 인덱스

mesh.GetBaseVertex(0), // [3] 베이스 버텍스

0 // [4] 시작 인스턴스

};

argsBuffer.SetData(args);

// GPU가 이 5개 uint를 직접 읽고

// 내부적으로 for문 실행:

// for (i = 0; i < args[1]; i++)

// RenderOneInstance(i, args[0], ...);

5개 uint의 의미:

[0] indexCount

삼각형의 정점 수

[1] instanceCount ★

몇 개 그릴 것인가

이것만 바꾸면 동적 LOD

[2] startIndex

서브메쉬 시작점

[3] baseVertex

버텍스 오프셋

[4] startInstance

보통 0 고정

메모리: 딱 20바이트!

14 of 79

SV_InstanceID — GPU가 부여하는 고유 번호

// PipeInstanced_URP.shader

StructuredBuffer<float4x4> _InstanceMatrices;

struct Attributes

{

float4 positionOS : POSITION;

float3 normalOS : NORMAL;

uint instanceID : SV_InstanceID; // ★

};

Varyings vert(Attributes input)

{

Varyings output;

// ★ 이 인스턴스의 변환 매트릭스

float4x4 mat =

_InstanceMatrices[input.instanceID];

float4 worldPos = mul(mat, input.positionOS);

output.positionCS =

TransformWorldToHClip(worldPos.xyz);

output.normalWS = normalize(

mul((float3x3)mat, input.normalOS));

return output;

}

// Day 2의 SV_VertexID와 동일한 패턴:

// SV_VertexID → 정점별 고유 인덱스

// SV_InstanceID → 인스턴스별 고유 인덱스

SV_InstanceID

GPU가 자동으로 부여

Instance 0 → ID = 0

Instance 1 → ID = 1

...

Instance 9999 → ID = 9999

이 ID로 Buffer 조회:

• _InstanceMatrices[id]

→ 위치/회전/스케일

• _SensorData[id]

→ 센서 값 (Step 07)

Day 2 비교:

SV_VertexID → _Vertices[vid]

SV_InstanceID → _Matrices[iid]

15 of 79

Matrix4x4.TRS — 파이프 레이아웃 생성

// PipeInstancer.cs (43~81줄)

void GenerateLayout()

{

gridSide = Mathf.CeilToInt(

Mathf.Sqrt(instanceCount));

matrices = new Matrix4x4[instanceCount];

for (int i = 0; i < instanceCount; i++)

{

int gx = i % gridSide;

int gz = i / gridSide;

float x = gx * spacing - offsetX;

float z = gz * spacing - offsetZ;

// 3층 구조 (0m, 4m, 8m)

int level = ((gx + gz * 3) * 7) % 3;

float y = level * levelHeight;

// Perlin Noise로 미세 변위

x += (Noise(gx*0.3f, gz*0.1f) - 0.5f)

* spacing * 0.4f;

// 랜덤 지름/높이

float diameter = 0.15f + Noise(...)*0.25f;

float height = 1.0f + Noise(...) * 3.0f;

matrices[i] = Matrix4x4.TRS(

new Vector3(x, y, z), rot,

new Vector3(diameter, height, diameter));

}

}

TRS 분해:

T = Translation (위치)

R = Rotation (회전)

S = Scale (크기)

→ 4×4 매트릭스 1개로 압축

3층 파이프:

Level 0: 수평 (90° 회전)

Level 1: 수직 (기본)

Level 2: 대각선

Perlin Noise 변위:

정확한 그리드 → 부자연스러움

Noise로 미세 변위 → 실제처럼

메모리:

10K × 64B = 640KB

16 of 79

Adjacency Buffer — 이웃 연결 정보

// PipeInstancer.cs (84~97줄)

void BuildAdjacency()

{

// 파이프당 4개 이웃 (상하좌우)

adjacency = new int[instanceCount * 4];

for (int i = 0; i < instanceCount; i++)

{

int gx = i % gridSide;

int gz = i / gridSide;

// 왼쪽

adjacency[i*4 + 0] = (gx > 0)

? i - 1 : -1;

// 오른쪽

adjacency[i*4 + 1] = (gx < gridSide - 1)

? i + 1 : -1;

// 뒤쪽 (Z-)

adjacency[i*4 + 2] = (gz > 0)

? i - gridSide : -1;

// 앞쪽 (Z+)

adjacency[i*4 + 3] = (i + gridSide < count)

? i + gridSide : -1;

}

}

// -1 = 경계 (이웃 없음)

왜 Adjacency Buffer인가?

Day 2 Wake (2D Texture):

이웃 = id.xy + int2(1,0)

→ UV 좌표로 자동 접근

Day 3 Heat (1D Buffer):

이웃 = adjacency[idx*4+i]

→ 명시적 연결 정보 필요

실무 확장:

• 임의의 그래프 구조 표현

(트리, 링, 분기 배관)

• 실제 CAD 연결 정보 적용

• adjacency만 바꾸면

같은 셰이더 재사용

17 of 79

Bounds 설정 — 프러스텀 컬링의 함정

DrawMeshInstancedIndirect는 개별 Transform이 없음

→ Unity가 인스턴스별 컬링을 할 수 없음

→ renderBounds 하나로 전체 가시성 판단

bounds가 카메라 밖이면?

→ 10,000개 전체가 한꺼번에 사라짐!

해결: 충분히 큰 bounds를 수동 설정

renderBounds = new Bounds(Vector3.zero, new Vector3(extent, height*4, extent));

라이브 데모:

bounds를 Vector3.one * 1f로 줄여보기 → 카메라 움직이면 파이프 전체 깜빡임

→ 다시 원래 크기로 복원하면 정상

18 of 79

CPU 시간 분석 — 10K vs 100K

수강생 질문: '10,000개는 4ms인데 100,000개는 왜 30ms?'

답: CPU가 느린 게 아니라 GPU를 기다리는 것

10,000 인스턴스:

• DrawMeshInstancedIndirect() → ~0.1ms

• GPU 렌더링 → ~3ms

• 결과: ~4ms/frame → 250 FPS

100,000 인스턴스:

• DrawMeshInstancedIndirect() → ~0.1ms

• GPU 렌더링 → ~29ms

• CPU: Gfx.WaitForPresent → ~29ms (GPU 대기!)

• 결과: ~30ms/frame → 33 FPS

Profiler: Window > Analysis > Profiler → Rendering → Gfx.WaitForPresent

해결: 그림자 끄기, 메쉬 LOD, 셰이더 간소화, GPU Culling

19 of 79

OnDestroy — ComputeBuffer 해제 (필수!)

GPU 메모리 누수 방지:

void OnDestroy()

{

matrixBuffer?.Release();

sensorBuffer?.Release();

argsBuffer?.Release();

adjacencyBuffer?.Release();

}

?. 연산자:

null이면 Release 호출 안 함 (NullReferenceException 방지)

이 패턴은 Day 2 OceanSimulator.OnDestroy()와 완전히 동일!

Play/Stop 반복 시:

Release 빠뜨리면 GPU 메모리가 Play할 때마다 누적 → 에디터 크래시

20 of 79

Draw Call 비교 시각화

═══ 방식 1: 개별 GameObject (10,000 Draw Calls) ═══

CPU: [SetPass][Draw] [SetPass][Draw] [SetPass][Draw] [SetPass][Draw] ... ×10,000

──────── 30ms+ ────────

GPU: [렌더][렌더][렌더][렌더]... [대부분 GPU가 놀고 있음!]

CPU가 명령을 보내느라 GPU가 할 일을 기다림

═══ 방식 3: DrawMeshInstancedIndirect (1~2 Draw Calls) ═══

CPU: [SetPass][DrawIndirect] 끝!

─── 0.1ms ───

GPU: [10,000개 인스턴스를 GPU 내부에서 병렬 렌더링]

──────── 3ms ────────

CPU는 즉시 해방 → 물리/AI/로직에 시간 할당 가능

Frame Debugger (Window > Analysis > Frame Debugger):

Before: RenderForward.RenderLoopJob ×10,000

After: DrawMeshInstancedIndirect ×1~2

Frame Debugger에서 Draw Call 수 직접 확인 가능

21 of 79

Step 06 — 라이브 데모

[ 라이브 데모 화면 / 스크린샷 삽입 ]

Scene: 06_GPUInstancing | Frame Debugger에서 Draw Call 1~2 확인 | instanceCount 조절로 성능 변화 관찰

22 of 79

2

Per-Instance 히트맵

같은 메쉬, 10,000개의 서로 다른 색상

23 of 79

핵심 개념 — Per-Instance Data

Step 06에서 배운 것:

• SV_InstanceID → 인스턴스별 '위치'가 다름

• _InstanceMatrices[instanceID] → 고유 Transform

Step 07에서 추가:

• SV_InstanceID → 인스턴스별 '데이터'도 다름

• _SensorData[instanceID] → 고유 센서값 (0.0 ~ 1.0)

StructuredBuffer 하나만 더 추가하면 끝:

CPU: float[] → ComputeBuffer → SetBuffer()

GPU: StructuredBuffer<float> → SV_InstanceID로 읽기

10,000 × sizeof(float) = 40KB/프레임 → GPU 전송 부담 거의 없음

24 of 79

SensorSimulator — 센서 데이터 시뮬레이션

// SensorSimulator.cs

void Update()

{

float modeOffset = (int)currentMode * 137.5f;

// 정상 센서값 (Perlin Noise)

for (int i = 0; i < count; i++)

{

float noiseX = i * 0.08f + modeOffset;

float noiseY = Time.time * 0.25f;

sensorValues[i] =

PerlinNoise(noiseX, noiseY) * 0.35f;

}

// 이상 발생 중이면

if (anomalyIndex >= 0)

{

anomalyTimer += Time.deltaTime;

float danger = Clamp01(

anomalyTimer / anomalyDuration);

// 이상 파이프: 0.35 → 1.0 (10초)

sensorValues[anomalyIndex] =

0.35f + danger * 0.65f;

// 주변 전파 (거리 반비례)

for (int off = 1; off <= 8; off++)

sensorValues[idx +/- off] =

Max(val, danger * 0.6f / off);

}

sensorBuffer.SetData(sensorValues);

}

3가지 모드:

• Temperature (온도)

• Pressure (압력)

• Vibration (진동)

→ Noise 시드만 다름

이상 발생:

TriggerAnomaly(id)

10초에 걸쳐 점진적 상승

파랑 → 노랑 → 빨강

주변 전파:

spreadRadius = 8칸

거리에 반비례 감소

실무 확장:

SensorSimulator 대신

MQTT/TCP 수신기 연결

→ 실제 Digital Twin!

25 of 79

데이터 흐름: CPU → GPU → 화면

CPU (SensorSimulator.cs) GPU (HeatmapInstanced_URP.shader)

sensorValues[0] = 0.12 ─┐

sensorValues[1] = 0.65 │ StructuredBuffer<float> _SensorData

sensorValues[2] = 0.03 │ SetData() │

sensorValues[3] = 0.91 ├──────────────→ │ SV_InstanceID로 읽기

... │ │

sensorValues[9999] = 0.28 ┘ ▼

Vertex Shader:

output.sensorValue = _SensorData[instanceID];

Fragment Shader:

val = saturate(sensorValue);

if (val < 0.5)

color = lerp(Cool, Warn, val * 2.0);

else

color = lerp(Warn, Hot, (val - 0.5) * 2.0);

값: 0.0 0.25 0.5 0.75 1.0

색상: ===========================================================

파랑(Cool) 청록 노랑(Warn) 주황 빨강(Hot)

정상 주의 위험

3단계 그라디언트: lerp로 2구간 보간 | val < 0.5 → Cool~Warn | val >= 0.5 → Warn~Hot

26 of 79

Heatmap Shader — 3색 그라디언트

// HeatmapInstanced_URP.shader (frag)

StructuredBuffer<float> _SensorData;

StructuredBuffer<float4x4> _InstanceMatrices;

Varyings vert(Attributes input)

{

float4x4 mat =

_InstanceMatrices[input.instanceID];

// ... 위치/법선 변환 ...

output.sensorValue =

_SensorData[input.instanceID]; // ★

return output;

}

float4 frag(Varyings input) : SV_Target

{

float val = saturate(input.sensorValue);

// ── 3색 보간 ──

float3 color;

if (val < 0.5)

color = lerp(_CoolColor.rgb,

_WarnColor.rgb,

val * 2.0); // [0.0~0.5]

else

color = lerp(_WarnColor.rgb,

_HotColor.rgb,

(val-0.5) * 2.0); // [0.5~1.0]

// ── Lambert 라이팅 ──

Light mainLight = GetMainLight();

float NdotL = saturate(

dot(normalWS, mainLight.direction));

color *= (NdotL * 0.6 + 0.4);

return float4(color, 1.0);

}

핵심:

Vertex에서 sensorValue 읽기

Fragment에서 색상으로 변환

lerp 2구간:

val 0.0 → 파랑 (Cool)

val 0.5 → 노랑 (Warn)

val 1.0 → 빨강 (Hot)

왜 vertex에서 읽나?

fragment에서 SV_InstanceID

사용 불가 (vertex semantic)

→ vertex에서 읽고

varying으로 전달

Lambert:

NdotL * 0.6 + 0.4

→ 0.4 = ambient floor

27 of 79

Alert Emission + Selection Highlight

// HeatmapInstanced_URP.shader (frag 계속)

// ── Alert Emission Pulse ──

if (val > _AlertThreshold) // 0.8

{

float pulse = sin(_Time.y * 8.0)

* 0.5 + 0.5; // 0~1 깜빡임

float intensity =

(val - _AlertThreshold) // 초과량

/ (1.0 - _AlertThreshold); // 정규화

color += _HotColor.rgb

* pulse * intensity * 2.0;

}

// ── Selection Highlight (Fresnel) ──

uint curID = input.instanceID + 1;

uint selID = (uint)_SelectedID;

if (curID == selID)

{

float3 viewDir = normalize(

_WorldSpaceCameraPos - posWS);

float fresnel = pow(

1.0 - saturate(

dot(normalWS, viewDir)),

2.5);

color = lerp(color,

float3(1,1,0.2), fresnel * 0.9);

color += float3(0.15, 0.15, 0.0);

}

Alert Emission:

val > 0.8 → 빨간 깜빡임

sin(_Time.y * 8.0) → 초당 8번

초과량에 비례해서 강도 증가

별도 포스트프로세싱 불필요!

Selection Highlight:

_SelectedID는 GPUPicker가 설정

Fresnel = 가장자리 강조

dot(normal, viewDir) 작을수록

→ 측면에서 밝게

pow(x, 2.5):

→ 가장자리만 좁게 하이라이트

이 셰이더 하나로:

히트맵 + 경고 + 선택

= 3가지 시각화 통합

28 of 79

ComputeBuffer 파이프라인 요약

Step 06 버퍼:

matrixBuffer : ComputeBuffer(10K, 64) // 위치/회전/스케일

argsBuffer : ComputeBuffer(1, 20, IndirectArguments)

Step 07 추가:

sensorBuffer : ComputeBuffer(10K, 4) // float 센서값

Step 08 추가:

(pickingRT : RenderTexture, linear) // 클릭 시 1회

Step 09 추가:

heatBufferA : ComputeBuffer(10K, 4) // Double buffer A

heatBufferB : ComputeBuffer(10K, 4) // Double buffer B

adjacencyBuffer : ComputeBuffer(10K*4, 4) // 이웃 연결 정보

총 GPU 메모리: ~640KB + 40KB + 40KB + 40KB + 160KB ≈ 920KB

→ GPU VRAM 수 GB 대비 <0.1% 사용

29 of 79

Shader 작성 체크리스트 — URP Custom Shader

URP 커스텀 셰이더 필수 요소:

1. Tags {"RenderPipeline"="UniversalPipeline"}

→ URP임을 선언, 없으면 빌트인으로 폴백

2. Tags {"LightMode"="UniversalForward"}

→ URP가 이 Pass를 Forward 렌더링에 포함

3. #include "...Core.hlsl" + #include "...Lighting.hlsl"

→ TransformWorldToHClip(), GetMainLight() 등 함수 제공

4. StructuredBuffer 선언 (Properties가 아닌 HLSLPROGRAM 내부)

→ GPU 버퍼는 Properties 블록에 넣을 수 없음

5. SV_InstanceID는 vertex semantic만 가능

→ vertex에서 읽고 TEXCOORD로 varying 전달

ShadowCaster Pass를 추가하면 Instanced 그림자도 생성 가능!

30 of 79

Step 07 — 라이브 데모

[ 라이브 데모 화면 / 스크린샷 삽입 ]

파란색 파이프 → [Trigger Anomaly] → 서서히 빨갛게 | Mode 전환 (Temp/Pressure/Vib)

31 of 79

3

GPU Color Picking

Collider 없이 16,777,215개 인스턴스 식별

32 of 79

왜 Raycast로는 안 되나?

일반 오브젝트:

• GameObject → Collider → Physics.Raycast()

• Hit 정보에서 오브젝트 참조 가능 ✓

DrawMeshInstancedIndirect:

• 메쉬 1개 + 매트릭스 10,000개

• 씬에 GameObject가 10,000개가 아님!

• 개별 Collider 없음 → Raycast 불가 ✗

10,000개 Collider 추가하면?

가능하지만 메모리 + PhysX 부하 → Instancing의 장점 상실

해결: GPU Color Picking

'보이는 대로' 판단 — 인스턴스 ID를 색으로 인코딩 → 클릭 좌표 읽기

33 of 79

GPU Picking 원리

핵심 아이디어:

사람 눈에는 보이지 않는 '2번째 렌더링'을 별도 RenderTexture에 수행

이때 색상 대신 Instance ID를 RGB로 인코딩

예시:

Instance 0 → RGB( 1, 0, 0) ← +1 오프셋

Instance 255 → RGB( 0, 1, 0)

Instance 1000 → RGB(232, 3, 0)

배경 (없음) → RGB( 0, 0, 0)

인코딩: id+1 → R = (id+1) & 0xFF, G = ((id+1) >> 8) & 0xFF, B = ((id+1) >> 16) & 0xFF

디코딩: id = (R | (G << 8) | (B << 16)) - 1

24비트 = 2^24 = 16,777,216개까지 식별 가능

34 of 79

GPU Picking — 전체 워크플로

마우스 좌클릭!

① CommandBuffer 생성 + 실행

┌───────────────────────────────────────────────────────┐

│ cmd.SetRenderTarget(pickingRT) // 별도 RT │

│ cmd.ClearRenderTarget(Color.clear) // 배경 = 0,0,0 │

│ cmd.SetViewProjectionMatrices(cam) // 같은 카메라 │

│ cmd.DrawMeshInstancedIndirect( // ID 셰이더로 │

│ pipeMesh, pickingMaterial, argsBuffer) │

│ Graphics.ExecuteCommandBuffer(cmd) │

└───────────────────────────────────────────────────────┘

② 클릭 좌표에서 1픽셀 읽기

RenderTexture.active = pickingRT

readbackTex.ReadPixels(new Rect(mx, my, 1, 1), 0, 0)

③ RGB → 정수 ID 디코딩

Color32 pixel = readbackTex.GetPixel(0, 0)

int id = (pixel.r | (pixel.g << 8) | (pixel.b << 16)) - 1

├─ id == -1 (배경 클릭) → 선택 해제

└─ id >= 0 (파이프 클릭) → material.SetFloat("_SelectedID", id+1)

OnInstanceSelected?.Invoke(id)

성능: 클릭할 때만 1회 렌더링 → 매 프레임 비용 0

35 of 79

InstancePicking.shader — ID 인코딩

// InstancePicking.shader

Shader "Hidden/InstancePicking"

{

SubShader {

Tags {"RenderPipeline"="UniversalPipeline"}

Pass {

HLSLPROGRAM

#pragma vertex vert

#pragma fragment frag

#include "...Core.hlsl"

StructuredBuffer<float4x4> _InstanceMatrices;

struct Attributes {

float4 positionOS : POSITION;

uint instanceID : SV_InstanceID;

};

struct Varyings {

float4 positionCS : SV_POSITION;

nointerpolation uint instanceID

: TEXCOORD0; // ★ 보간 금지!

};

Varyings vert(Attributes input) {

Varyings o;

float4x4 m =

_InstanceMatrices[input.instanceID];

o.positionCS = TransformWorldToHClip(

mul(m, input.positionOS).xyz);

o.instanceID = input.instanceID + 1;

return o;

}

float4 frag(Varyings i) : SV_Target {

uint id = i.instanceID;

float r = ((id) & 0xFF) / 255.0;

float g = ((id >> 8) & 0xFF) / 255.0;

float b = ((id >>16) & 0xFF) / 255.0;

return float4(r, g, b, 1.0);

}

ENDHLSL

}}

}

핵심 포인트:

① nointerpolation:

래스터라이저가 삼각형 내부에서

instanceID를 보간하면 안됨!

정수값 그대로 유지 필수

② +1 오프셋:

ID 0 → RGB(1,0,0)

배경 → RGB(0,0,0)

→ 0으로 '클릭 실패' 감지

③ 비트 시프트:

하위 8비트 → R

중간 8비트 → G

상위 8비트 → B

→ 24비트 정수 커버

36 of 79

GPUPicker.cs — CommandBuffer 설정

// GPUPicker.cs

void Initialize(PipeInstancer instancer)

{

cam = Camera.main;

// ★ Linear RT 생성 (sRGB 변환 방지!)

var desc = new RenderTextureDescriptor(

cam.pixelWidth, cam.pixelHeight,

RenderTextureFormat.ARGB32, 24);

desc.sRGB = false; // ★★★

pickingRT = new RenderTexture(desc);

// ★ Linear readback texture

readbackTex = new Texture2D(

1, 1, TextureFormat.ARGB32,

false, true); // linear = true

// CommandBuffer

cmd = new CommandBuffer();

cmd.name = "InstancePicking";

// Picking Material 설정

pickingMaterial.SetBuffer(

"_InstanceMatrices",

instancer.MatrixBuffer);

}

CommandBuffer란?

GPU 명령을 미리 기록해두고

나중에 한 번에 실행

→ 카메라 렌더링과 독립

핵심 설정:

desc.sRGB = false:

감마 변환 비활성화

ID가 정수이므로 색 보정하면

값이 변질됨!

Texture2D(linear:true):

ReadPixels도 Linear 모드

→ sRGB ↔ Linear 일관성

37 of 79

DoPick — 클릭 시 실행되는 픽셀 읽기

// GPUPicker.cs

void DoPick(Vector2 screenPos)

{

// ── CommandBuffer 기록 ──

cmd.Clear();

cmd.SetRenderTarget(pickingRT);

cmd.ClearRenderTarget(

true, true, Color.clear);

cmd.SetViewProjectionMatrices(

cam.worldToCameraMatrix,

cam.projectionMatrix);

cmd.DrawMeshInstancedIndirect(

pipeMesh, 0, pickingMaterial,

0, argsBuffer);

Graphics.ExecuteCommandBuffer(cmd);

// ── 1픽셀 읽기 ──

int mx = Mathf.Clamp(

(int)screenPos.x, 0, pickingRT.width-1);

int my = Mathf.Clamp(

(int)screenPos.y, 0, pickingRT.height-1);

RenderTexture.active = pickingRT;

readbackTex.ReadPixels(

new Rect(mx, my, 1, 1), 0, 0);

readbackTex.Apply();

RenderTexture.active = null;

// ── 디코딩 ──

Color32 c = readbackTex.GetPixel(0, 0);

int id = (c.r | (c.g << 8) | (c.b << 16)) - 1;

selectedId = id;

OnInstanceSelected?.Invoke(id);

}

실행 순서:

① cmd.Clear()

이전 명령 초기화

② SetRenderTarget

화면이 아닌 pickingRT에

③ DrawMeshInstancedIndirect

ID 셰이더로 재렌더링

④ ReadPixels(1×1)

클릭 좌표에서 딱 1픽셀

⑤ Color32 디코딩

byte → int 직접 변환

(float 변환 오차 방지)

38 of 79

★ 핵심 함정: Linear 색 공간 + sRGB RT

문제 상황:

URP 프로젝트 = Linear 색 공간이 기본

RenderTexture 기본값이 sRGB = true!

잘못된 파이프라인:

셰이더 출력: ID 10 → R = 10/255 = 0.039

sRGB 감마 변환: 0.039^(1/2.2) → 0.211

양자화: round(0.211 × 255) = 54

역변환: (54/255)^2.2 → 0.032 × 255 = 8

기대값 10인데 결과 8! → 엉뚱한 파이프!

해결:

① RenderTextureDescriptor.sRGB = false

② Texture2D(..., linear: true)

③ Color32로 바이트 직접 사용 (float 변환 회피)

이 3가지를 모르면 클릭이 아예 안 되거나 엉뚱한 파이프가 선택됨

39 of 79

nointerpolation — 왜 필요한가?

래스터라이저가 하는 일:

삼각형의 3개 정점에서 계산된 값을 내부 픽셀로 '보간'(interpolation)

일반 데이터 (position, normal, UV):

보간이 바람직함 — 부드러운 렌더링

Instance ID:

정점 A: instanceID = 42

정점 B: instanceID = 42

정점 C: instanceID = 42 (같은 인스턴스)

보간: 42.0 → (float 정밀도 문제로) 41.9999... → (int) 41!

nointerpolation 키워드:

삼각형 첫 번째 정점 값을 전체 픽셀에 그대로 전달 (Flat interpolation)

정수 ID가 변질 없이 보존됨 ✓

40 of 79

CommandBuffer란? — 렌더링 명령 큐

일반 렌더링:

Unity가 Camera.Render()에서 자동으로 모든 Draw 명령 실행

→ 개발자가 끼어들 여지 없음

CommandBuffer:

GPU 명령을 '기록'해두고, 원하는 시점에 '실행'

var cmd = new CommandBuffer();

cmd.SetRenderTarget(pickingRT); // RT 전환

cmd.ClearRenderTarget(true, true, Color.clear);

cmd.SetViewProjectionMatrices(V, P); // 카메라 행렬

cmd.DrawMeshInstancedIndirect(...); // ID 셰이더로 렌더

Graphics.ExecuteCommandBuffer(cmd); // 실행!

사용 사례:

• GPU Picking (지금) • 커스텀 포스트프로세스 • Shadow 커스텀

41 of 79

Step 08 — 라이브 데모

[ 라이브 데모 화면 / 스크린샷 삽입 ]

파이프 좌클릭 → ID/센서값/위치 표시 + 노란색 Fresnel 아웃라인 | 배경 클릭 → 선택 해제

42 of 79

오전 세션 정리 — 핵심 키워드

Step 06 키워드:

DrawMeshInstancedIndirect, IndirectArguments, SV_InstanceID

StructuredBuffer<float4x4>, Matrix4x4.TRS, Bounds

Step 07 키워드:

Per-Instance Data, StructuredBuffer<float>, 3색 그라디언트

lerp, saturate, Alert Emission (sin), Fresnel Highlight

Step 08 키워드:

GPU Color Picking, CommandBuffer, RenderTexture

24비트 RGB 인코딩, nointerpolation, sRGB vs Linear

패턴:

CPU에서 ComputeBuffer 생성 → GPU에서 StructuredBuffer로 읽기

SV_InstanceID가 모든 인스턴스 기능의 핵심 열쇠

이 3개 Step의 기반 위에 Step 09~10을 쌓습니다

43 of 79

4

ComputeShader 열 전파

Day 2 Wake Diffusion 패턴 재활용

44 of 79

Day 2 Wake vs Day 3 Heat — 비교 정리

데이터 구조:

Wake: RWTexture2D<float> (2D 이미지 — 512×512)

Heat: RWStructuredBuffer<float> (1D 배열 — 10,000개)

numthreads:

Wake: (8, 8, 1) → 2D 블록

Heat: (256, 1, 1) → 1D 배열

이웃 접근:

Wake: id.xy + int2(1,0) → 좌표 기반 (자동)

Heat: _AdjacencyBuffer[idx*4+i] → 그래프 기반 (명시적)

주입 트리거:

Wake: 선박 위치 (자동/연속)

Heat: 마우스 클릭 (수동/이벤트)

핵심 수학: 동일! newVal = current × decay + (neighborAvg - current) × diffusion

45 of 79

RWTexture2D vs RWStructuredBuffer

RWTexture2D<float> (Day 2 Wake)

• 2D 좌표 (x, y)로 접근

• 이웃 접근: id.xy + int2(1, 0) → 즉시

• 하드웨어 보간(bilinear) 지원

• GPU 캐시 지역성 우수 (2D 블록 단위)

• 사용 시: 데이터가 규칙적 2D 그리드일 때

RWStructuredBuffer<float> (Day 3 Heat)

• 1D 인덱스로 접근

• 이웃 접근: _AdjacencyBuffer[idx*4+i] → 별도 버퍼 필요

• 임의 struct 저장 가능 (float4x4 등)

• 비정규 그래프 구조 표현 가능

• 사용 시: 데이터가 1D 배열이거나 비정규 연결일 때

Day 3는 파이프 배열(1D)이고 이웃이 비정규 → StructuredBuffer 적합

46 of 79

CSHeatInject — Pass 1: 열 주입

// HeatPropagation.compute

#pragma kernel CSHeatInject // Pass 1

#pragma kernel CSHeatUpdate // Pass 2

RWStructuredBuffer<float> _HeatBuffer;

StructuredBuffer<float> _HeatBufferRead;

StructuredBuffer<int> _AdjacencyBuffer;

float _Decay, _Diffusion;

float _NoiseScale, _NoiseAmplitude;

float _Time;

int _PipeCount;

int _InjectionIndex;

float _InjectionValue;

// ─── Pass 1: 열 주입 ───

[numthreads(256, 1, 1)]

void CSHeatInject(

uint3 id : SV_DispatchThreadID)

{

uint idx = id.x;

if (idx >= (uint)_PipeCount) return;

// 미세한 배경 변동

float ambient = noise1D(

(float)idx * _NoiseScale

+ _Time * 0.3)

* _NoiseAmplitude;

_HeatBuffer[idx] = max(

_HeatBuffer[idx], ambient);

// 클릭 지점에 열 주입

if ((int)idx == _InjectionIndex)

_HeatBuffer[idx] = max(

_HeatBuffer[idx],

_InjectionValue);

}

핵심:

max() 사용:

기존값과 비교해서 큰 쪽

→ 누적 대신 바닥값 보장

ambient noise:

미세한 배경 변동

완전히 0이 아닌 살아있는 느낌

amplitude: 0.02

injection:

_InjectionIndex >= 0일 때만

해당 파이프에 value 주입

기본: -1 (비활성)

Day 2 비교:

CSWakeStamp:

→ 선박 위치에 V자 찍기

47 of 79

CSHeatUpdate — Pass 2: 감쇠 + 확산

// HeatPropagation.compute

[numthreads(256, 1, 1)]

void CSHeatUpdate(

uint3 id : SV_DispatchThreadID)

{

uint idx = id.x;

if (idx >= (uint)_PipeCount) return;

float current = _HeatBufferRead[idx];

// 이웃 4개 값 합산

float neighborSum = 0.0;

int neighborCount = 0;

for (int i = 0; i < 4; i++)

{

int n = _AdjacencyBuffer[idx * 4 + i];

if (n >= 0) // -1 = 경계

{

neighborSum += _HeatBufferRead[n];

neighborCount++;

}

}

float neighborAvg = (neighborCount > 0)

? neighborSum / (float)neighborCount

: current;

// ★ 핵심 수식

float newVal = current * _Decay

+ (neighborAvg - current) * _Diffusion;

_HeatBuffer[idx] = newVal;

}

수식 분해:

current × Decay:

자연 감쇠 (or 증폭)

< 1.0: 식음, > 1.0: 증폭

(avgN - current) × Diff:

이웃이 나보다 뜨거우면 +

이웃이 나보다 차면 -

→ 열이 고온에서 저온으로 이동

예시 (D=1.005, Df=0.45):

A=1.0, B=0.0

A → 1.0×1.005

+ (0.0-1.0)×0.45

= 0.555

B → 0.0×1.005

+ (1.0-0.0)×0.45

= 0.450

48 of 79

Double Buffering — Read/Write 충돌 방지

═══ 버퍼 1개만 사용하면? (Race Condition!) ═══

Thread 0: buffer[0] = f( buffer[0], buffer[1] ) → buffer[0]이 먼저 변경됨

Thread 1: buffer[1] = f( buffer[1], buffer[0] ) → 이미 변경된 buffer[0]을 읽음!

결과: 실행 순서에 따라 결과가 다름

═══ 올바른 방식: Ping-Pong Double Buffering ═══

Frame N:

Read: current = bufferA (읽기 전용, 불변)

Write: next = bufferB (쓰기 전용)

Thread 0: bufferB[0] = f( bufferA[0], bufferA[1] ) ← A 불변!

Thread 1: bufferB[1] = f( bufferA[1], bufferA[0] ) ← A 불변!

Frame N+1:

Read: current = bufferB ← 역할 교환!

Write: next = bufferA

pingPong = !pingPong;

═══ Material Rebind (필수!) ═══

스왑 후:

material.SetBuffer("_SensorData", nextBuffer); ← 히트맵 셰이더에 최신 버퍼 전달

안 하면? 히트맵이 이전 프레임 데이터를 표시 → 1프레임 밀림

Day 2 Wake에서도 동일한 패턴 사용 | GPU 수천 스레드 동시 실행 시 Read/Write 분리 필수

49 of 79

HeatPropagationManager — Dispatch + 더블 버퍼

// HeatPropagationManager.cs

void DispatchOneStep()

{

ComputeBuffer readBuf, writeBuf;

if (pingPong) {

readBuf = heatBufferA;

writeBuf = heatBufferB;

} else {

readBuf = heatBufferB;

writeBuf = heatBufferA;

}

// Pass 1: Inject

int k0 = heatCompute.FindKernel(

"CSHeatInject");

heatCompute.SetBuffer(k0,

"_HeatBuffer", writeBuf);

heatCompute.SetFloat(

"_InjectionIndex", injectionIdx);

heatCompute.SetFloat(

"_InjectionValue", 1.0f);

heatCompute.Dispatch(k0, groups, 1, 1);

// Pass 2: Update

int k1 = heatCompute.FindKernel(

"CSHeatUpdate");

heatCompute.SetBuffer(k1,

"_HeatBufferRead", readBuf);

heatCompute.SetBuffer(k1,

"_HeatBuffer", writeBuf);

heatCompute.Dispatch(k1, groups, 1, 1);

// ★ Material rebind

targetMaterial.SetBuffer(

"_SensorData", writeBuf);

pingPong = !pingPong;

}

실행 순서:

① readBuf/writeBuf 결정

pingPong으로 교대

② CSHeatInject Dispatch

writeBuf에 주입 (max)

③ CSHeatUpdate Dispatch

readBuf 읽기 → writeBuf 쓰기

④ Material rebind ★

Heatmap 셰이더가 최신 읽도록

⑤ pingPong 스왑

다음 프레임 역할 교환

groups 계산:

10K / 256 = 40 그룹

50 of 79

시뮬레이션 주파수 제한 — 25Hz

// HeatPropagationManager.cs

float simAccumulator = 0f;

void Update()

{

if (heatCompute == null) return;

// ── 시간 누적기: 25Hz ──

simAccumulator += Time.deltaTime;

float stepInterval = 1f / simulationHz;

if (simAccumulator < stepInterval)

return; // 아직 때가 아님

simAccumulator -= stepInterval;

// Spiral of Death 방지

if (simAccumulator > stepInterval * 3f)

simAccumulator = 0f;

// 이하 시뮬레이션 1스텝...

DispatchOneStep();

}

// ─── 파라미터 강제 설정 ───

public void Configure(

float decay, float diffusion,

float noiseAmplitude, float simulationHz)

{

this.decay = decay;

this.diffusion = diffusion;

// ...

}

// ⚠️ [SerializeField] 함정:

// 코드 기본값 vs 씬 저장값 → 씬이 우선!

// → Configure()로 Start()에서 강제 덮어쓰기

왜 주파수 제한?

273 FPS에서 매 프레임:

273 스텝/초 × 0.45 확산

→ 0.3초 만에 평형

→ 전파가 '안 보임'

25 Hz로 제한:

25 스텝/초

→ 평형까지 5~10초

→ 전파가 눈에 보임!

파라미터 가이드:

Decay <1.0: 열이 식음

Decay =1.0: 유지

Decay >1.0: 증폭

Diffusion ↑: 빠르게 확산

51 of 79

파라미터 튜닝 가이드

현재 설정: decay=1.005, diffusion=0.45, noise=0.02, Hz=25

Decay:

• 0.99 → 열이 빠르게 식음 (실제 냉각 모사)

• 1.00 → 주입된 열이 유지 (에너지 보존)

• 1.005 → 약간 증폭 (연쇄 반응 느낌, 데모에 적합) ★

Diffusion:

• 0.1 → 느린 전파, 열원 주변에 집중

• 0.45 → 중간 속도, 눈에 보이는 전파 ★

• 0.9 → 매우 빠른 확산, 전체가 균일해짐

simulationHz:

• 10Hz → 느린 전파, 스텝이 보임

• 25Hz → 자연스러운 속도 ★

• 60Hz → 빠른 전파, 렌더링과 동기

52 of 79

numthreads(256,1,1) — 1D Dispatch 계산

═══ Day 2 (2D Texture) ═══

numthreads(8, 8, 1) → 한 그룹 = 64 스레드 (8×8 타일)

Dispatch(512/8, 512/8, 1) = Dispatch(64, 64, 1)

총 스레드: 64 × 64 × 64 = 262,144 (512×512 텍셀)

═══ Day 3 (1D Buffer) ═══

numthreads(256, 1, 1) → 한 그룹 = 256 스레드 (1D 배열)

Dispatch(10000/256, 1, 1) = Dispatch(40, 1, 1)

총 스레드: 40 × 256 = 10,240 (10,000개 파이프 + 여분)

// 여분 스레드 보호:

if (idx >= (uint)_PipeCount) return;

// 10,000~10,239번 스레드는 즉시 종료

// C# 계산:

int groups = Mathf.CeilToInt(instanceCount / 256f);

heatCompute.Dispatch(kernel, groups, 1, 1);

// 256 선택 이유:

// GPU warp = 32 스레드 → 256 = 32 × 8 (warp 정렬)

// 레지스터 사용량 적을 때 256이 최적

numthreads는 GPU Occupancy에 직접 영향 | 256은 대부분의 GPU에서 적합한 기본값

53 of 79

Step 09 — 라이브 데모

[ 라이브 데모 화면 / 스크린샷 삽입 ]

파이프 클릭 → 열이 사방으로 5~10초에 걸쳐 퍼짐 | 'Day 2 Wake와 같은 수학입니다'

54 of 79

오후 세션 로드맵

Step 09: ComputeShader 열 전파 (이어서)

• 파라미터 튜닝 실습

• Day 2 Wake 수식과 비교 검증

Step 10: 통합 Dashboard

• 5개 컴포넌트 오케스트레이션

• AlertSystem + OrbitCamera 포커스

• DashboardUI — 시계열 그래프

마무리:

• 수강생 함정 Top 10

• 성능 분석 — GPU 연산량

• 실무 확장 방향

55 of 79

5

통합 Dashboard

Smart Engine Room Monitoring System

56 of 79

컴포넌트 구성 — 5개의 오케스트레이션

EngineRoomManager (지휘자)

├─ PipeInstancer GPU Instancing 렌더링 (Step 06)

├─ SensorSimulator 센서 데이터 시뮬레이션 (Step 07)

├─ GPUPicker 마우스 클릭 → 파이프 선택 (Step 08)

├─ HeatPropagation ComputeShader 열 전파 (Step 09)

├─ AlertSystem 임계치 모니터링 → 카메라 포커스

└─ DashboardUI FPS / 선택정보 / 경고 / 시계열 그래프

초기화 순서 (순서 중요!):

Awake(): PipeInstancer.Initialize() → 데이터 준비

Start(): 나머지 모든 컴포넌트 Initialize() → 데이터 소비

실행 순서 함정: 데이터 제공자는 반드시 Awake(), 소비자는 Start()

57 of 79

Step10Wiring — 컴포넌트 연결

// Step10Wiring.cs

void Start()

{

// PipeInstancer는 Awake()에서 이미 초기화됨

var instancer =

GetComponent<PipeInstancer>();

var sensor = GetComponent<SensorSimulator>();

sensor.Initialize(

instancer.InstanceCount,

instancer.Mat);

var picker = GetComponent<GPUPicker>();

picker.Initialize(instancer);

var alert = GetComponent<AlertSystem>();

alert.Initialize(instancer, sensor);

var heat =

GetComponent<HeatPropagationManager>();

heat.Configure(

decay: 1.005f, diffusion: 0.45f,

noiseAmplitude: 0.02f,

simulationHz: 25f);

heat.Initialize(instancer, instancer.Mat);

var dashboard = GetComponent<DashboardUI>();

dashboard.Initialize(

instancer, sensor, picker, alert);

}

Wiring 패턴:

각 컴포넌트가 독립적

Start()에서 참조 연결

→ 테스트/교체 용이

Configure() 호출:

[SerializeField] 기본값이

씬 저장값에 의해 덮어씌이는

문제를 런타임에서 강제 수정

초기화 순서:

PipeInstancer → Awake

나머지 모두 → Start

Awake는 Start보다

반드시 먼저 실행

58 of 79

AlertSystem — 임계치 모니터링

// AlertSystem.cs

void Update()

{

alertPipes.Clear();

for (int i = 0; i < count; i++)

{

float val = sensor.GetSensorValue(i);

if (val > alertThreshold) // 0.8

{

alertPipes.Add(i);

if (alertPipes.Count == 1)

OnAlert?.Invoke(i, val);

}

}

if (autoFocus && alertPipes.Count > 0)

FocusOnPipe(alertPipes[0]);

}

void FocusOnPipe(int pipeIndex)

{

Vector3 pos =

instancer.GetPipePosition(pipeIndex);

// ★ OrbitCamera에 위임

orbitCam.FocusOn(pos, focusDistance);

}

Alert 조건:

sensorValue > 0.8

→ alertPipes 리스트에 추가

카메라 포커스:

★ 직접 카메라 이동 금지!

OrbitCamera.FocusOn()으로 위임

이유:

AlertSystem이 cam.position 직접 설정

→ OrbitCamera.LateUpdate()가 덮어씀

→ 카메라가 떨림 / 안 움직임

해결:

OrbitCamera가 target을 Lerp

→ 부드러운 전환

→ 사용자 팬 시 자동 취소

59 of 79

OrbitCamera — 포커스 위임 패턴

// OrbitCamera.cs

Vector3? focusTarget = null;

public void FocusOn(

Vector3 worldPos, float dist)

{

focusTarget = worldPos;

distance = dist;

}

void LateUpdate()

{

// 포커스 요청이 있으면 target을 Lerp

if (focusTarget.HasValue)

{

target = Vector3.Lerp(

target, focusTarget.Value,

Time.deltaTime * 3f);

if (Vector3.Distance(

target, focusTarget.Value) < 0.1f)

focusTarget = null; // 도착

}

// 중클릭(팬) 시 포커스 취소

if (Input.GetMouseButton(2))

{

focusTarget = null; // 사용자 제어 우선

// ... 기존 팬 로직 ...

}

// Orbit 계산

Quaternion rot = Quaternion.Euler(

pitchAngle, yawAngle, 0);

Vector3 pos = target

- rot * Vector3.forward * distance;

transform.SetPositionAndRotation(pos, rot);

}

핵심 원칙:

카메라 제어자는 항상 1개!

Vector3? nullable:

null = 포커스 없음

값 = 포커스 목표

Lerp:

즉시 이동 X

3f 속도로 부드럽게

→ 자연스러운 카메라 전환

중클릭 취소:

사용자가 직접 조작하면

포커스 즉시 해제

→ 자동 vs 수동 전환 자연스러움

60 of 79

DashboardUI — 프로그래밍 방식 UI

Canvas 런타임 생성:

Canvas canvas = new GameObject("DashboardCanvas").AddComponent<Canvas>();

canvas.renderMode = RenderMode.ScreenSpaceOverlay;

4개 패널:

1. Stats Panel — FPS, 인스턴스 수, Draw Call

2. Selected Panel — 클릭한 파이프 ID, 센서값, 위치

3. Alert Panel — 경고 파이프 수, 최고 센서값

4. Graph Panel — 선택 파이프의 시계열 히트 값 (RawImage)

Graphic 컴포넌트 함정:

• Image + RawImage를 같은 GameObject에 추가하면 NullReferenceException!

• Unity는 Graphic 컴포넌트를 1개만 허용 (Image, RawImage, Text)

• 해결: 별도 자식 GameObject에 RawImage 배치

61 of 79

시계열 그래프 — Texture2D 직접 그리기

방법: 128×64 Texture2D에 매 프레임 센서값을 1열씩 기록

// 새 열에 현재 센서값 기록

int col = frameCount % graphWidth;

for (int y = 0; y < graphHeight; y++)

{

float threshold = (float)y / graphHeight;

Color c = sensorValue > threshold ? hotColor : bgColor;

graphTex.SetPixel(col, y, c);

}

graphTex.Apply();

Texture2D 직접 그리기:

• 외부 라이브러리 불필요

• 128×64 = 8,192 픽셀/프레임 → CPU 부하 미미

• RawImage에 texture 할당 → UI에 표시

• 스크롤: frameCount % width → 자동 순환

62 of 79

Auto Demo 모드

목적: 강의 시연 중 수동 조작 없이 자동으로 이상 발생 → 관제 → 리셋 반복

시퀀스:

1. 15초 대기 (정상 상태)

2. 랜덤 파이프에 TriggerAnomaly()

3. 센서값 상승 → alertThreshold(0.8) 초과

4. AlertSystem → OnAlert 이벤트

5. OrbitCamera.FocusOn() → 카메라 이동

6. DashboardUI 경고 패널 업데이트

7. 15초 후 ResetAnomaly()

8. 1번으로 돌아가 반복

프레젠터가 해야 할 일: Play 버튼 누르고 설명에 집중

63 of 79

Script Execution Order — Awake vs Start

Unity 스크립트 라이프사이클:

Awake() → OnEnable() → Start() → Update() → LateUpdate()

핵심 규칙:

• 같은 프레임에서 모든 Awake() → 모든 Start() 순서로 호출

• 다른 오브젝트의 Awake() 순서는 비결정적!

우리 시스템:

PipeInstancer.Awake() → matrices[], adjacency[] 생성

Step10Wiring.Start() → instancer.Adjacency 사용

만약 PipeInstancer가 Start()에서 초기화하면?

→ Wiring.Start()보다 먼저 실행된다는 보장 없음

→ NullReferenceException!

원칙: 데이터 제공자 = Awake() | 데이터 소비자 = Start()

64 of 79

전체 스크립트 연결 구조

PipeInstancer (Awake)

├─→ MatrixBuffer, SensorBuffer, ArgsBuffer, AdjacencyBuffer

├─→ instanceCount, Mat (material)

└─→ renderBounds

SensorSimulator (Start)

├── receives: instanceCount, material (from PipeInstancer)

└── writes: sensorBuffer.SetData() every frame

GPUPicker (Start)

├── receives: pipeMesh, matrixBuffer, argsBuffer

└── fires: OnInstanceSelected(int id)

HeatPropagationManager (Start)

├── receives: adjacencyBuffer, material

└── writes: heatBuffer → material._SensorData (25Hz)

AlertSystem (Start)

├── receives: instancer, sensor

└── fires: OnAlert(int id, float val), orbitCam.FocusOn()

DashboardUI (Start)

└── receives: all above → UI 표시

65 of 79

Step 10 — 라이브 데모

[ 라이브 데모 화면 / 스크린샷 삽입 ]

Auto Demo 모드 | 이상 감지 → 카메라 포커스 | 클릭 → 시계열 그래프 | Digital Twin 프로토타입

66 of 79

6

함정 정리 & 성능 분석

실무에서 반드시 알아야 할 것들

67 of 79

수강생이 놓치기 쉬운 함정 Top 10 (1/2)

1. Bounds 너무 작게 설정

증상: 카메라 이동 시 파이프 전체 사라짐 | 해결: 충분히 큰 bounds

2. sRGB RenderTexture로 Picking

증상: 클릭해도 엉뚱한 파이프 선택 | 해결: desc.sRGB = false

3. [SerializeField] 기본값 vs 씬 저장값

증상: 코드 변경이 Inspector에 반영 안됨 | 해결: Configure() 런타임 강제

4. Script Execution Order

증상: Start()에서 NullReferenceException | 해결: 제공자=Awake(), 소비자=Start()

5. Double Buffer 미적용

증상: 열 전파 결과 불일치 | 해결: ping-pong 더블 버퍼링

68 of 79

수강생이 놓치기 쉬운 함정 Top 10 (2/2)

6. Material 버퍼 rebind 누락

증상: 히트맵이 1프레임씩 밀림 | 해결: 스왑 후 SetBuffer() 재호출

7. 시뮬레이션 Hz 무제한

증상: 273FPS에서 전파가 즉시 완료 | 해결: 시간 누적기로 25Hz 제한

8. OrbitCamera + 직접 카메라 이동 충돌

증상: 카메라 포커스가 안됨 | 해결: OrbitCamera.FocusOn()으로 위임

9. Graphic 컴포넌트 중복 (Image + RawImage)

증상: UI 생성 시 NullReferenceException | 해결: 별도 GameObject에 분리

10. ComputeBuffer Release 누락

증상: Play/Stop 반복 시 GPU 메모리 누수 | 해결: OnDestroy에서 buffer?.Release()

69 of 79

전체 GPU 연산량 분석

PipeInstancer 렌더링:

• 10,000 인스턴스 × ~2,000 삼각형 → 매 프레임 (1~2 Draw Call)

• ShadowCaster 포함 시 ×2

SensorSimulator:

• 10,000 float 업로드 = 40KB → 매 프레임 (CPU→GPU)

HeatPropagation:

• 2,500 스레드 × 2패스 = 5,000 스레드 → 25Hz

• GPU 비용: ~0.01ms/step (매우 가벼움)

GPU Picking:

• 10,000 인스턴스 재렌더 + 1px readback → 클릭 시 1회만

Alert 스캔:

• 10,000 float 순회 → 매 프레임 (CPU, ~0.1ms)

CPU 병목: sensorBuffer.SetData(40KB/frame) → 개선: ComputeShader로 이동

70 of 79

GPU ComputeShader 적용 분야

게임:

• 파티클 시스템, 유체 시뮬레이션, 절차적 지형 생성

• GPU Skinning, Cloth Simulation

Digital Twin:

• 대규모 IoT 센서 데이터 시각화 ← Day 3가 이것!

• CFD (전산유체역학) 실시간 근사

• 구조물 응력 분포 히트맵

AI/ML:

• Unity ML-Agents 관측 처리

• 추론 후처리 (NMS, 필터링)

XR:

• Spatial Computing 공간 메싱

• 대규모 포인트 클라우드 렌더링

71 of 79

확장 아이디어

Level 1 — 센서 데이터 연결:

• SensorSimulator → MQTT/TCP 수신기 교체

• 실제 PLC/SCADA 데이터 바인딩

→ 코드 변경: SensorSimulator 1개 파일만

Level 2 — GPU Culling:

• ComputeShader에서 Frustum 테스트

• 보이는 인스턴스만 argsBuffer에 기록

→ 100K에서도 60FPS 유지 가능

Level 3 — Addressables 통합:

• 엔진룸을 구역(Section)으로 분할

• 구역 진입 시 비동기 로드 → GPU Instancing

→ Day 3 범위 확장: 메모리 최적화

Level 4 — WebGL 배포:

• Unity WebGL 빌드 → 브라우저에서 실행

→ 별도 앱 설치 없이 대시보드 접근

72 of 79

권장 라이브 데모 순서

Step 06: GPU Instancing (10분)

1. 10K 파이프 확인 2. Frame Debugger 3. instanceCount 변경 4. bounds 테스트

Step 07: Heatmap (10분)

5. 전체 파란색 확인 6. Trigger Anomaly 7. Mode 전환 8. Reset

Step 08: GPU Picking (10분)

9. 파이프 클릭 → ID 표시 10. 빨간 파이프 클릭 11. 배경 클릭

Step 09: Heat Propagation (15분)

12. 클릭 → 열 전파 13. 다른 위치 클릭 14. Reset 15. Day 2 Wake 비교

Step 10: Dashboard (15분)

16. Auto Demo 관찰 17. 카메라 포커스 18. 클릭 → 그래프 19. Mode 전환

마무리 (10분):

20. Frame Debugger 21. 전체 복습 22. 실무 확장 토론

73 of 79

전체 StructuredBuffer 정리

Buffer 이름

_InstanceMatrices StructuredBuffer<float4x4> 10K × 64B = 640 KB

_SensorData StructuredBuffer<float> 10K × 4B = 40 KB

_HeatBufferA/B RWStructuredBuffer<float> 10K × 4B = 80 KB (×2)

_AdjacencyBuffer StructuredBuffer<int> 40K × 4B = 160 KB

argsBuffer IndirectArguments 1 × 20B = 20 B

총 GPU 메모리: ~920 KB (< 1 MB)

GPU VRAM 4~16 GB 대비 0.01% 미만

StructuredBuffer vs ComputeBuffer:

• C# 측: ComputeBuffer (실체)

• HLSL 측: StructuredBuffer<T> (참조)

• material.SetBuffer()로 이름 매핑

이름이 다르지만 동일한 GPU 메모리를 가리킴!

74 of 79

Day 3 핵심 정리 — 가져갈 것

GPU Instancing:

DrawMeshInstancedIndirect → 1~2 Draw Call로 수만 개 렌더링

SV_InstanceID로 각 인스턴스가 고유 데이터 소유

GPU Picking:

Collider 없이 인스턴스 식별 — RGB 24비트 인코딩

Linear/sRGB 색 공간 이해가 필수

ComputeShader 열 전파:

Day 2 Wake와 동일한 수학: current × decay + (neighborAvg - current) × diffusion

Double Buffering + 주파수 제한으로 안정적 시뮬레이션

통합 Dashboard:

실시간 모니터링 + 알림 + 상호작용 → Digital Twin의 기본 골격

MQTT/TCP만 연결하면 실제 시스템으로 전환 가능

75 of 79

참고 자료

Unity Documentation:

• Graphics.DrawMeshInstancedIndirect — Unity Scripting Reference

• ComputeShader — Unity Manual

• ComputeBuffer — Unity Scripting Reference

Shader 참고:

• URP ShaderLibrary — Packages/com.unity.render-pipelines.universal/ShaderLibrary/

• SV_InstanceID — Microsoft HLSL Semantics

• Color Space — Unity Manual > Rendering > Color Space

기법:

• GPU Color Picking — 'GPU-based Object Picking' (GPU Gems)

• Heat Equation — Discrete Laplacian (Wikipedia)

• Double Buffering — 'Ping-Pong Technique' (Computer Graphics)

76 of 79

실습 과제

과제 1 (기초):

Step 06에서 instanceCount를 50,000으로 변경하고 Profiler로 성능 측정

→ Draw Call 수, CPU 시간, GPU 시간 기록

과제 2 (중급):

Step 07에서 4번째 모드(Humidity) 추가 — Noise 시드와 색상 변경

→ SensorSimulator에 enum 추가, 셰이더 색상 프로퍼티 조정

과제 3 (중급):

Step 08의 Picking RT를 Game View에 디버그 표시하기

→ Camera.onPostRender에서 pickingRT를 화면 구석에 표시

과제 4 (고급):

Step 09의 decay를 0.95로 변경하고 열이 사라지는 시간 관찰

→ 주입 후 몇 초 만에 0.1 이하로 내려가는지 측정

과제 5 (도전):

SensorSimulator를 CSV 파일에서 읽도록 수정

→ 미리 준비된 센서 데이터를 시간 순서대로 재생

77 of 79

디버깅 팁 — Frame Debugger 활용

Window > Analysis > Frame Debugger

확인할 것:

1. Draw Call 수 — DrawMeshInstancedIndirect가 1~2개인지 확인

2. 셰이더 프로퍼티 — _InstanceMatrices, _SensorData 바인딩 확인

3. RenderTexture — pickingRT의 sRGB 상태 확인

Profiler:

1. CPU: Gfx.WaitForPresent → GPU가 병목

2. GPU: RenderThread → 셰이더 복잡도

3. Memory: ComputeBuffer 할당량 확인

Console 패턴:

• NullReferenceException → Awake/Start 순서 확인

• 'Compute buffer not set' → SetBuffer 누락

• 화면이 깜빡임 → Bounds 크기 확인

78 of 79

Q & A

질문이 있으시면 말씀해 주세요

79 of 79

Day 3 완료!

GPU Instancing + Heatmap + Picking + Heat Propagation + Dashboard

Day 2에서 배운 ComputeShader 패턴을

실무 Digital Twin 프로토타입으로 확장했습니다.

수고하셨습니다!