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
학습 목표
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에서 다른 형태로 재등장합니다.
미니 프로젝트: Smart Engine Room Dashboard
시나리오:
거대한 선박 엔진룸에 10,000개의 파이프가 설치되어 있다.
각 파이프에는 온도/압력/진동 센서가 장착되어 있고,
관제사가 실시간으로 파이프 상태를 모니터링한다.
요구사항:
① 10,000개 파이프를 끊김 없이 렌더링
② 각 파이프의 센서 데이터를 색상으로 실시간 표시
③ 파이프를 클릭하면 상세 정보 표시
④ 이상 감지 시 카메라 자동 포커스 + 경고 UI
⑤ 클릭한 파이프에서 열이 주변으로 전파되는 시뮬레이션
5개 기술 = 5개 데모 씬 (Step 06 → 10), 마지막에 통합
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가 추가되는 구조
→ 점진적 학습 (누적형 데모)
전체 아키텍처 — 데이터 흐름
[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 수학
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로 변경.
오전 세션 로드맵
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은 오후 세션에서 진행
핵심 용어 정리
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 패턴)
1
GPU Instancing
10,000개를 Draw Call 1~2로 렌더링하는 방법
실무에서 왜 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
렌더링 방식 비교 — 세 가지 접근법
방식 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가 처리
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
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바이트!
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]
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
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만 바꾸면
같은 셰이더 재사용
Bounds 설정 — 프러스텀 컬링의 함정
DrawMeshInstancedIndirect는 개별 Transform이 없음
→ Unity가 인스턴스별 컬링을 할 수 없음
→ renderBounds 하나로 전체 가시성 판단
bounds가 카메라 밖이면?
→ 10,000개 전체가 한꺼번에 사라짐!
해결: 충분히 큰 bounds를 수동 설정
renderBounds = new Bounds(Vector3.zero, new Vector3(extent, height*4, extent));
라이브 데모:
bounds를 Vector3.one * 1f로 줄여보기 → 카메라 움직이면 파이프 전체 깜빡임
→ 다시 원래 크기로 복원하면 정상
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
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할 때마다 누적 → 에디터 크래시
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 수 직접 확인 가능
Step 06 — 라이브 데모
[ 라이브 데모 화면 / 스크린샷 삽입 ]
Scene: 06_GPUInstancing | Frame Debugger에서 Draw Call 1~2 확인 | instanceCount 조절로 성능 변화 관찰
2
Per-Instance 히트맵
같은 메쉬, 10,000개의 서로 다른 색상
핵심 개념 — 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 전송 부담 거의 없음
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!
데이터 흐름: 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
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
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가지 시각화 통합
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% 사용
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 그림자도 생성 가능!
Step 07 — 라이브 데모
[ 라이브 데모 화면 / 스크린샷 삽입 ]
파란색 파이프 → [Trigger Anomaly] → 서서히 빨갛게 | Mode 전환 (Temp/Pressure/Vib)
3
GPU Color Picking
Collider 없이 16,777,215개 인스턴스 식별
왜 Raycast로는 안 되나?
일반 오브젝트:
• GameObject → Collider → Physics.Raycast()
• Hit 정보에서 오브젝트 참조 가능 ✓
DrawMeshInstancedIndirect:
• 메쉬 1개 + 매트릭스 10,000개
• 씬에 GameObject가 10,000개가 아님!
• 개별 Collider 없음 → Raycast 불가 ✗
10,000개 Collider 추가하면?
가능하지만 메모리 + PhysX 부하 → Instancing의 장점 상실
해결: GPU Color Picking
'보이는 대로' 판단 — 인스턴스 ID를 색으로 인코딩 → 클릭 좌표 읽기
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개까지 식별 가능
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
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비트 정수 커버
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 일관성
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 변환 오차 방지)
★ 핵심 함정: 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가지를 모르면 클릭이 아예 안 되거나 엉뚱한 파이프가 선택됨
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가 변질 없이 보존됨 ✓
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 커스텀
Step 08 — 라이브 데모
[ 라이브 데모 화면 / 스크린샷 삽입 ]
파이프 좌클릭 → ID/센서값/위치 표시 + 노란색 Fresnel 아웃라인 | 배경 클릭 → 선택 해제
오전 세션 정리 — 핵심 키워드
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을 쌓습니다
4
ComputeShader 열 전파
Day 2 Wake Diffusion 패턴 재활용
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
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 적합
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자 찍기
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
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 분리 필수
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 그룹
시뮬레이션 주파수 제한 — 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 ↑: 빠르게 확산
파라미터 튜닝 가이드
현재 설정: 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 → 빠른 전파, 렌더링과 동기
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에서 적합한 기본값
Step 09 — 라이브 데모
[ 라이브 데모 화면 / 스크린샷 삽입 ]
파이프 클릭 → 열이 사방으로 5~10초에 걸쳐 퍼짐 | 'Day 2 Wake와 같은 수학입니다'
오후 세션 로드맵
Step 09: ComputeShader 열 전파 (이어서)
• 파라미터 튜닝 실습
• Day 2 Wake 수식과 비교 검증
Step 10: 통합 Dashboard
• 5개 컴포넌트 오케스트레이션
• AlertSystem + OrbitCamera 포커스
• DashboardUI — 시계열 그래프
마무리:
• 수강생 함정 Top 10
• 성능 분석 — GPU 연산량
• 실무 확장 방향
5
통합 Dashboard
Smart Engine Room Monitoring System
컴포넌트 구성 — 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()
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보다
반드시 먼저 실행
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
→ 부드러운 전환
→ 사용자 팬 시 자동 취소
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 수동 전환 자연스러움
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 배치
시계열 그래프 — 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 → 자동 순환
Auto Demo 모드
목적: 강의 시연 중 수동 조작 없이 자동으로 이상 발생 → 관제 → 리셋 반복
시퀀스:
1. 15초 대기 (정상 상태)
2. 랜덤 파이프에 TriggerAnomaly()
3. 센서값 상승 → alertThreshold(0.8) 초과
4. AlertSystem → OnAlert 이벤트
5. OrbitCamera.FocusOn() → 카메라 이동
6. DashboardUI 경고 패널 업데이트
7. 15초 후 ResetAnomaly()
8. 1번으로 돌아가 반복
프레젠터가 해야 할 일: Play 버튼 누르고 설명에 집중
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()
전체 스크립트 연결 구조
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 표시
Step 10 — 라이브 데모
[ 라이브 데모 화면 / 스크린샷 삽입 ]
Auto Demo 모드 | 이상 감지 → 카메라 포커스 | 클릭 → 시계열 그래프 | Digital Twin 프로토타입
6
함정 정리 & 성능 분석
실무에서 반드시 알아야 할 것들
수강생이 놓치기 쉬운 함정 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 더블 버퍼링
수강생이 놓치기 쉬운 함정 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()
전체 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로 이동
GPU ComputeShader 적용 분야
게임:
• 파티클 시스템, 유체 시뮬레이션, 절차적 지형 생성
• GPU Skinning, Cloth Simulation
Digital Twin:
• 대규모 IoT 센서 데이터 시각화 ← Day 3가 이것!
• CFD (전산유체역학) 실시간 근사
• 구조물 응력 분포 히트맵
AI/ML:
• Unity ML-Agents 관측 처리
• 추론 후처리 (NMS, 필터링)
XR:
• Spatial Computing 공간 메싱
• 대규모 포인트 클라우드 렌더링
확장 아이디어
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 빌드 → 브라우저에서 실행
→ 별도 앱 설치 없이 대시보드 접근
권장 라이브 데모 순서
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. 실무 확장 토론
전체 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 메모리를 가리킴!
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만 연결하면 실제 시스템으로 전환 가능
참고 자료
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)
실습 과제
과제 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 파일에서 읽도록 수정
→ 미리 준비된 센서 데이터를 시간 순서대로 재생
디버깅 팁 — 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 크기 확인
Q & A
질문이 있으시면 말씀해 주세요
Day 3 완료!
GPU Instancing + Heatmap + Picking + Heat Propagation + Dashboard
Day 2에서 배운 ComputeShader 패턴을
실무 Digital Twin 프로토타입으로 확장했습니다.
수고하셨습니다!