[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
---
|
||||
id: game-loop-ecs
|
||||
title: Game Loop / ECS — 매 frame / Entity-Component-System
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [game, ecs, architecture, vibe-coding]
|
||||
tech_stack: { language: "TS / Various", applicable_to: ["Game", "Frontend"] }
|
||||
applied_in: []
|
||||
aliases: [game loop, fixed timestep, ECS, entity-component-system, bevy, bitECS]
|
||||
---
|
||||
|
||||
# Game Loop / ECS
|
||||
|
||||
> Game = 매 frame update + render. **Fixed timestep + interpolation**. 큰 game = ECS (Entity-Component-System). data-oriented + cache-friendly.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Frame: 16ms (60fps).
|
||||
- Fixed timestep: physics 결정성.
|
||||
- ECS: Entity (id) + Component (data) + System (logic).
|
||||
- Data-oriented: cache locality.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### 기본 game loop
|
||||
```ts
|
||||
let last = performance.now();
|
||||
function frame(now: number) {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
|
||||
update(dt);
|
||||
render();
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
```
|
||||
|
||||
→ `dt` 가 frame 차이. 30fps 든 120fps 든 같은 속도.
|
||||
|
||||
### Fixed timestep (physics)
|
||||
```ts
|
||||
const FIXED = 1 / 60;
|
||||
let acc = 0;
|
||||
let last = performance.now();
|
||||
|
||||
function frame(now: number) {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
acc += dt;
|
||||
|
||||
while (acc >= FIXED) {
|
||||
fixedUpdate(FIXED); // physics — 항상 같은 step
|
||||
acc -= FIXED;
|
||||
}
|
||||
|
||||
const alpha = acc / FIXED; // interpolation
|
||||
render(alpha);
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
```
|
||||
|
||||
→ "Fix Your Timestep!" 패턴. multiplayer / replay 일관.
|
||||
|
||||
### 단순 entity 직접
|
||||
```ts
|
||||
class Player {
|
||||
x = 0; y = 0; vx = 0; vy = 0;
|
||||
hp = 100;
|
||||
|
||||
update(dt: number) {
|
||||
this.x += this.vx * dt;
|
||||
this.y += this.vy * dt;
|
||||
}
|
||||
|
||||
render(ctx: CanvasRenderingContext2D) {
|
||||
ctx.fillRect(this.x, this.y, 32, 32);
|
||||
}
|
||||
}
|
||||
|
||||
const player = new Player();
|
||||
const enemies = [...]; // Enemy[]
|
||||
|
||||
function update(dt: number) {
|
||||
player.update(dt);
|
||||
for (const e of enemies) e.update(dt);
|
||||
}
|
||||
```
|
||||
|
||||
→ 작은 게임 OK. 100+ entity = ECS.
|
||||
|
||||
### ECS — bitECS (TS, fast)
|
||||
```ts
|
||||
import { createWorld, addEntity, addComponent, defineComponent, defineQuery, defineSystem, Types, pipe } from 'bitecs';
|
||||
|
||||
const world = createWorld();
|
||||
|
||||
const Position = defineComponent({ x: Types.f32, y: Types.f32 });
|
||||
const Velocity = defineComponent({ vx: Types.f32, vy: Types.f32 });
|
||||
const Sprite = defineComponent({ texture: Types.ui8 });
|
||||
|
||||
// Entity 생성
|
||||
const player = addEntity(world);
|
||||
addComponent(world, Position, player);
|
||||
addComponent(world, Velocity, player);
|
||||
Position.x[player] = 100;
|
||||
Position.y[player] = 100;
|
||||
Velocity.vx[player] = 50;
|
||||
|
||||
// System — Position + Velocity 둘 다 가진 entity
|
||||
const moveQuery = defineQuery([Position, Velocity]);
|
||||
const moveSystem = defineSystem((world) => {
|
||||
const ents = moveQuery(world);
|
||||
for (const eid of ents) {
|
||||
Position.x[eid] += Velocity.vx[eid] * world.dt;
|
||||
Position.y[eid] += Velocity.vy[eid] * world.dt;
|
||||
}
|
||||
});
|
||||
|
||||
const renderQuery = defineQuery([Position, Sprite]);
|
||||
const renderSystem = defineSystem((world) => {
|
||||
for (const eid of renderQuery(world)) {
|
||||
drawSprite(Sprite.texture[eid], Position.x[eid], Position.y[eid]);
|
||||
}
|
||||
});
|
||||
|
||||
const pipeline = pipe(moveSystem, renderSystem);
|
||||
|
||||
function frame(dt: number) {
|
||||
world.dt = dt;
|
||||
pipeline(world);
|
||||
}
|
||||
```
|
||||
|
||||
→ Component = SoA (Struct of Arrays). Cache-friendly.
|
||||
|
||||
### Why ECS?
|
||||
```
|
||||
OOP class:
|
||||
class Enemy { x, y, hp, ... }
|
||||
- 상속 hierarchy 의 함정 (Enemy extends Character)
|
||||
- 같은 class 의 instance = memory 분산
|
||||
|
||||
ECS:
|
||||
Entity = id, Component = data only
|
||||
- 어떤 component 조합도 가능
|
||||
- Cache-friendly iteration
|
||||
- System = pure function on data
|
||||
```
|
||||
|
||||
### Bevy (Rust ECS, web 가능)
|
||||
```rust
|
||||
use bevy::prelude::*;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_systems(Startup, setup)
|
||||
.add_systems(Update, (movement, collision))
|
||||
.run();
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct Velocity(Vec2);
|
||||
|
||||
fn movement(mut q: Query<(&mut Transform, &Velocity)>, time: Res<Time>) {
|
||||
for (mut t, v) in &mut q {
|
||||
t.translation += v.0.extend(0.0) * time.delta_seconds();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ WASM 빌드 → web game.
|
||||
|
||||
### Phaser (popular HTML5)
|
||||
```ts
|
||||
import Phaser from 'phaser';
|
||||
|
||||
class Scene extends Phaser.Scene {
|
||||
preload() {
|
||||
this.load.image('player', 'player.png');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.player = this.physics.add.sprite(100, 100, 'player');
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.cursors.left.isDown) this.player.setVelocityX(-200);
|
||||
else if (this.cursors.right.isDown) this.player.setVelocityX(200);
|
||||
else this.player.setVelocityX(0);
|
||||
}
|
||||
}
|
||||
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
width: 800, height: 600,
|
||||
scene: Scene,
|
||||
physics: { default: 'arcade' },
|
||||
});
|
||||
```
|
||||
|
||||
### PixiJS (rendering, 강력 2D)
|
||||
```ts
|
||||
import { Application, Sprite, Assets } from 'pixi.js';
|
||||
|
||||
const app = new Application();
|
||||
await app.init({ resizeTo: window });
|
||||
document.body.appendChild(app.canvas);
|
||||
|
||||
const tex = await Assets.load('player.png');
|
||||
const sprite = new Sprite(tex);
|
||||
app.stage.addChild(sprite);
|
||||
|
||||
app.ticker.add((time) => {
|
||||
sprite.x += time.deltaTime;
|
||||
});
|
||||
```
|
||||
|
||||
### Collision (AABB)
|
||||
```ts
|
||||
function aabb(a: Rect, b: Rect): boolean {
|
||||
return a.x < b.x + b.w &&
|
||||
a.x + a.w > b.x &&
|
||||
a.y < b.y + b.h &&
|
||||
a.y + a.h > b.y;
|
||||
}
|
||||
|
||||
// Quadtree 큰 게임
|
||||
import RBush from 'rbush';
|
||||
const tree = new RBush();
|
||||
tree.load(items);
|
||||
const collisions = tree.search(playerBox);
|
||||
```
|
||||
|
||||
### Audio
|
||||
```ts
|
||||
import { Howl } from 'howler';
|
||||
|
||||
const sfx = new Howl({ src: ['shoot.mp3'], volume: 0.5 });
|
||||
sfx.play();
|
||||
|
||||
const music = new Howl({ src: ['bgm.mp3'], loop: true, volume: 0.3 });
|
||||
music.play();
|
||||
```
|
||||
|
||||
### Input
|
||||
```ts
|
||||
const keys = new Set<string>();
|
||||
window.addEventListener('keydown', e => keys.add(e.key));
|
||||
window.addEventListener('keyup', e => keys.delete(e.key));
|
||||
|
||||
// 또는 gamepad
|
||||
const gp = navigator.getGamepads()[0];
|
||||
if (gp?.buttons[0].pressed) jump();
|
||||
```
|
||||
|
||||
### Networking (multiplayer)
|
||||
```ts
|
||||
// Authoritative server + client prediction + reconciliation
|
||||
const ws = new WebSocket('wss://server/game');
|
||||
|
||||
let serverState = ...;
|
||||
let predictedState = ...;
|
||||
let inputs: Input[] = [];
|
||||
|
||||
function frame(dt: number) {
|
||||
const input = readInput();
|
||||
inputs.push(input);
|
||||
predictedState = applyInput(predictedState, input, dt);
|
||||
ws.send(JSON.stringify(input));
|
||||
render(predictedState);
|
||||
}
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
const ack = JSON.parse(msg.data);
|
||||
// Reconcile: server state + 이후 inputs 재적용
|
||||
serverState = ack.state;
|
||||
predictedState = serverState;
|
||||
for (const i of inputs.slice(ack.lastInput + 1)) {
|
||||
predictedState = applyInput(predictedState, i, FIXED);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Skia (canvas / native 2D, fast)
|
||||
```
|
||||
Flutter / RN Skia / 자체 — 모바일 게임 / 차트.
|
||||
@shopify/react-native-skia.
|
||||
```
|
||||
|
||||
### 60fps budget
|
||||
```
|
||||
Frame: 16.6ms
|
||||
- Input: ~1ms
|
||||
- Update: ~5ms
|
||||
- Physics: ~3ms
|
||||
- Render: ~5ms
|
||||
- Idle: ~2ms
|
||||
```
|
||||
|
||||
→ 어떤 system 가 budget 초과?
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 게임 종류 | 추천 |
|
||||
|---|---|
|
||||
| 작은 / casual | 직접 + Canvas / DOM |
|
||||
| 큰 2D | Phaser / PixiJS |
|
||||
| 큰 game / multiplayer | ECS (bitECS / Bevy) |
|
||||
| 3D | Three.js / R3F / Bevy / Unity |
|
||||
| Mobile native | Unity / Unreal / Godot |
|
||||
| Cross-platform native | Bevy WASM + native target |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **`setInterval` for game loop**: drift / pause 문제. requestAnimationFrame.
|
||||
- **No fixed timestep + physics**: 다른 fps = 다른 결과.
|
||||
- **OOP 깊은 hierarchy**: 변경 어려움. ECS or composition.
|
||||
- **Render in update**: 분리.
|
||||
- **DOM nodes 큰 game**: Canvas / WebGL.
|
||||
- **Memory allocation 매 frame**: GC pause. pool / reuse.
|
||||
- **Audio 재생 매번 새 instance**: pool.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Fixed timestep + interpolation 표준.
|
||||
- ECS = 큰 게임의 답.
|
||||
- Phaser / Pixi 가 빠른 시작.
|
||||
- 60fps budget 기반 측정.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Frontend_Three_R3F]]
|
||||
- [[Frontend_WebGPU_Patterns]]
|
||||
- [[CS_Big_O_Practical]]
|
||||
Reference in New Issue
Block a user