web assembly를 통한 유체엔진 성능 향상 (성능 비교)

Cadi·2024년 8월 8일
2
post-thumbnail

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를 적용하고 성능차이를 확인해 보았습니다.

WebAssembly와 Rust

저는 웹 어셈블리를 구현하기 위해 rust를 선택하였습니다.
rust는 실행시간이 빠르고, 메모리 관리가 엄격합니다.
따라서 성능에 민감한 물리엔진에 알맞는 특성을 가졌다고 생각했습니다.

또한 wasm-bindgen,wasm-pack,wasm-opt 등 다양한 라이브러리들이 웹 어셈블리를 지원해주고 있어 환경구성이 간편했습니다.
(개인적으로 이전에 c++, emscription을 사용해 환경을 구성해본적이 있으나 난이도차이가 심한하다고 느꼈습니다.)

저는 유체의 충돌, 위치계산에 들어가는 연산을 전부 wasm으로 위임하고,
javascript를 통해서 canvas 제어및 브라우저 이벤트를 담당하도록 최적화를 진행하였습니다.

1. 기존의 클래스 이관하기

  • rust의 기본 원리를 이해하는데 꽤나 애를 먹었고, 단순히 언어공부가 목적이 아니였기 때문에 문제점을 마주할 때마다 해결책을 검색해가며 구현하였습니다.
  • rust의 소유권은 정말 창의적인것임이 틀림없습니다.
  • typescript
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;
  }
}
  • web assembly(rust)
#[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),
        }
    }
}

2. rust wasm 빌드 이후 typescript에서 클래스 형태로 불러와 실행하기

  • wasm-pack을 통해 빌드된 wasm 바이너리 파일들은 pkg/{pkg-name}.js 에서 불러올 수 있습니다.
  • #[wasm_bindgen] 어노테이션으로 지정된 함수와 클래스를 불러와 실행할 수 있습니다.
  • 저는 engine 자체를 wasm으로 빌드하였고, 각 프레임마다 update를 실행하며 물리엔진을 구현하였습니다.
  • typescript
import {
  Vector as rustVector,
  Universe,
} from '/rust-module/pkg/rust_module';
this.universe = new Universe(); // Engine을 불러옵니다.
this.universe.update(deltaTime);
  • web assembly(rust)
 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);
}

3.실행 결과를 Canvas에 표시하기

  • 실행결과는 javascript의 가비지 컬렉팅 힙과 분리되어 웹 어셈블리의 선형 메모리 공간에 저장됩니다.
  • 이를 canvas에서 사용하기 위해 메모리주소를 통한 접근으로 데이터를 불러와야 합니다.
  • typescript
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 점유율 또한 매우 개선되었습니다.

왼쪽이 wasm을 사용한 유체엔진 , 오른쪽이 기존의 유체엔진입니다

육안으로는 둘의 성능차이가 확연히 들어나지는 않습니다.
하지만 크롬의 cpu 사용량을 통해 명확한 성능차이를 확인할 수 있습니다.

1600 particle bench mark

왼쪽이 wasm을 사용한 유체엔진 , 오른쪽이 기존의 유체엔진입니다

1600 particle로 성능차이를 더 명확하게 확인하였습니다.
파티클이 많아질수록 성능차이가 확연히 느껴집니다.

느낀점

  • 솔직히 유체엔진의 프레임드랍 이슈때문에 구상하던 프로젝트를 포기할까 고민을 많이 했습니다. 하지만 멋지게 해결된듯하여 기분이 좋네요
  • rust로 엔진을 이관하며 이게 정말 그 정도의 성능차이를 내줄까? 고민을 많이했습니다. 아주 만족스럽네요. wasm 엔진은 2000 파티클까지는 버텨주는듯 합니다.
  • 가비지 컬렉터의 소중함을 느끼게되는 시간이였습니다. rust의 메모리관리와 소유권개념은 오른손잡이가 왼손으로 밥먹는것만큼 헷갈리는 경험이였습니다.
profile
글쓰는 개발자

0개의 댓글

관련 채용 정보