webassembly 적용 후기와 벤치마크에 대한 이야기 입니다.
현재 canvas, typescript를 이용한 물리엔진을 구현하고 있습니다.
강체의 충돌을 구현할 때엔 성능적으로 문제가 없었지만, 유체의 충돌을 구현할 때 typescript의 성능적 한계를 마주하였습니다.
유체를 구현할때 2005년 발표된 Particle-based Viscoelastic Fluid Simulation 논문을 참고하였습니다.
논문에 따라 유체를 표현하기 위해 particle을 사용하고 있습니다.
particle 은 작은 알갱이형태로 유체를 표현하고, 알갱이에 압력, 밀도, 점성, 탄력을 구현하여 흐름을 표현합니다.
제가 만든 엔진에서는 800개의 파티클이 넘어갔을 때 cpu 점유율이 90%이상으로 높아지고, 프레임 드랍현상 또한 발생하였습니다.
제 목표는 typescript로 물리엔진을 만들고 간단한 게임을 구성해서 서비스하는것이기 때문에 좀더 복잡한 유체를 표현할 수 있도록 성능의 개선이 필요했습니다.
double density relaxation이 병목현상을 발생시켰습니다.
double density relaxation은 하나의 particle을 기준으로 주변 particle을 검색하여 각각의 밀도를 계산하고 서로를 밀어내거나 끌어당기게 함으로서 유체의 흐름을 표현하는 것 입니다.
- 기준 particle과 near particle로 구분하여 알고리즘이 실행되며 최악의 경우 n^2의 시간복잡도를 가질 수 있습니다.
- 초당 60프레임을 유지하기 위해서라면 n^2의 연산을 초당 60번 실행하는 셈입니다.
- 물론 Hash Grid를 통한 오브젝트 검색 최적화는 적용되었지만 이것만으론 부족했던 모양이다...
따라서 평소에 관심있게 보던 web assembly를 적용하고 성능차이를 확인해 보았습니다.
저는 웹 어셈블리를 구현하기 위해 rust를 선택하였습니다.
rust는 실행시간이 빠르고, 메모리 관리가 엄격합니다.
따라서 성능에 민감한 물리엔진에 알맞는 특성을 가졌다고 생각했습니다.
또한 wasm-bindgen,wasm-pack,wasm-opt 등 다양한 라이브러리들이 웹 어셈블리를 지원해주고 있어 환경구성이 간편했습니다.
(개인적으로 이전에 c++, emscription을 사용해 환경을 구성해본적이 있으나 난이도차이가 심한하다고 느꼈습니다.)
저는 유체의 충돌, 위치계산에 들어가는 연산을 전부 wasm으로 위임하고,
javascript를 통해서 canvas 제어및 브라우저 이벤트를 담당하도록 최적화를 진행하였습니다.
- rust의 기본 원리를 이해하는데 꽤나 애를 먹었고, 단순히 언어공부가 목적이 아니였기 때문에 문제점을 마주할 때마다 해결책을 검색해가며 구현하였습니다.
- rust의 소유권은 정말 창의적인것임이 틀림없습니다.
class Particle {
position: Vector;
prevPosition: Vector;
velocity: Vector;
color: string;
constructor(position: Vector, color: string) {
this.position = position;
this.prevPosition = position;
this.velocity = new Vector({ x: 0, y: 0 });
this.color = color;
}
}
#[wasm_bindgen(getter_with_clone)]
#[repr(C)]
#[derive(Clone)]
pub struct Particle {
pub id: f64,
pub position: Vector,
pub prev_position: Vector,
pub velocity: Vector,
}
#[wasm_bindgen]
impl Particle {
#[wasm_bindgen(constructor)]
pub fn new(id:f64,position: Vector) -> Particle {
Particle {
id,
position: position.clone(),
prev_position: position.clone(),
velocity: Vector::new(0.0, 0.0),
}
}
}
- wasm-pack을 통해 빌드된 wasm 바이너리 파일들은 pkg/{pkg-name}.js 에서 불러올 수 있습니다.
- #[wasm_bindgen] 어노테이션으로 지정된 함수와 클래스를 불러와 실행할 수 있습니다.
- 저는 engine 자체를 wasm으로 빌드하였고, 각 프레임마다 update를 실행하며 물리엔진을 구현하였습니다.
import {
Vector as rustVector,
Universe,
} from '/rust-module/pkg/rust_module';
this.universe = new Universe(); // Engine을 불러옵니다.
this.universe.update(deltaTime);
pub fn update(&mut self, delta_time: f64) { // Engine을 업데이트 합니다.
self.apply_gravity();
self.predict_positions(&delta_time);
self.neighbor_search();
self.double_density_relaxation(&delta_time);
self.world_boundary();
self.compute_next_velocity(&delta_time);
}
- 실행결과는 javascript의 가비지 컬렉팅 힙과 분리되어 웹 어셈블리의 선형 메모리 공간에 저장됩니다.
- 이를 canvas에서 사용하기 위해 메모리주소를 통한 접근으로 데이터를 불러와야 합니다.
import init, { greet, fibonacci } from '../../../../rust-module/pkg/rust_module';
init().then(async (wasm) => {
registry.memory = wasm.memory; // 로드된 웹 어셈블리의 메모리를 저장합니다.
}
const particlesPtr = this.universe.particles(); // 검색을 원하는 변수의 메모리주소를 반환합니다.
const cells = new Float64Array(registry.memory.buffer, particlesPtr, {particlesLength} * {particleMemorySize}); // float64의 데이터형식으로 메모리의 데이터를 읽어 배열형태로 반환받습니다.
for (let i = 0; i < {particlesLength} * {particleMemorySize}; i += 7) {
// i is index of particle
// cells[i]; // particle id
// cells[i + 1]; // position X
// cells[i + 2]; // position Y
// cells[i + 3]; // prevPosition X
// cells[i + 4]; // prevPosition Y
// cells[i + 5]; // velocity X
// cells[i + 6]; // velocity Y
this.drawUtils.fillCircle(new Vector({ x: cells[i + 1], y: cells[i + 2] }), 5, 'blue'); // canvas 제어함수입니다. x,y의 중심 position을 벡터형태로 전달하여 원형으로 particle을 표현합니다.
}
더이상 800 particle에서 프레임 드랍 현상이 발생하지 않고 cpu 점유율 또한 매우 개선되었습니다.
![]() | ![]() |
---|
육안으로는 둘의 성능차이가 확연히 들어나지는 않습니다.
하지만 크롬의 cpu 사용량을 통해 명확한 성능차이를 확인할 수 있습니다.
1600 particle로 성능차이를 더 명확하게 확인하였습니다.
파티클이 많아질수록 성능차이가 확연히 느껴집니다.