Threejs 에서 GPGPU 사용 01

P5js
17 min readJan 14, 2024

--

많이들 사용하지만 한글로된 자료가 부족해서 적어보는..

WebGL을 개발하다 보면 욕심이 생기기 마련이고 어느쯤에 서는 웹이라는 제약적인 환경에서 퍼포먼스를 극대화해보고 싶은 생각이 들기 마련이다.
(단 레이 마칭 같은 프래그먼트 쉐이더만 사용하는 방법은 제외)

하지만 즐겁게 시작한 webgl 개발이 자료 부족? 때문에 고민에 빠질 때가 한두번이 아니다. 늘 그렇지만 최대한 쉽게 풀어보려하며 누군가에게는 도움이 되어 국내 개발자들도 GPGPU를 손쉽게 사용했으면 한다.(잘하는 사람은 물론 많다 )

GPGPU 란 무엇일까?
“General-purpose computing on graphics processing units”의 약자로

“컴퓨터 그래픽스를 위한 계산만 맡았던 그래픽 처리 장치를, 전통적으로 중앙 처리 장치가 맡았던 응용 프로그램들의 계산에 사용하는 기술이다”(위키)

GPU의 장점을 아는 분들은 잘 알겠지만 CPU와는 다른 병렬 연산에 있다.
아주 쉽게 설명한 https://thebookofshaders.com/01/?lan=kr 이곳을 참조하면 된다.

자 그렇다면 GPU는 무엇이고 GPGPU도 무엇인지 대강 알아보았다 그렇다면 실제 어떻게 사용할 수 있는지 확인해 보아야 할 시간이다.

WebGL 개발시 가장 많이 사용하는 Threejs 기준으로 적어보겠다.
사실 GPGPU의 기본적인 활용방법은 ping pong buffer 라는 방식을 사용하나 threejs 에서 이를 활용하게 편하게 만들어둔 “GPUComputationRenderer”를 사용해서 진행하려고 한다.

가장 먼저 webGL 환경의 기본이 되는 Scene, Camera, Renderer 등을 세팅하고

import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import GUI from 'lil-gui'

const gui = new GUI({ width: 340 })
const canvas = document.querySelector('canvas.webgl')

const scene = new THREE.Scene()

const geometry = new THREE.PlaneGeometry(2, 2, 128, 128)
const material = new THREE.MeshBasicMaterial()

const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)


const sizes = {
width: window.innerWidth,
height: window.innerHeight
}

window.addEventListener('resize', () =>
{

sizes.width = window.innerWidth
sizes.height = window.innerHeight

camera.aspect = sizes.width / sizes.height
camera.updateProjectionMatrix()

renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})

const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.set(0, 0, 3)
scene.add(camera)


const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true

const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

const clock = new THREE.Clock()

const onUpdate = () =>
{
const elapsedTime = clock.getElapsedTime()
controls.update()

renderer.render(scene, camera)
window.requestAnimationFrame(onUpdate)
}

onUpdate()

위의 과정이 제대로 작성 되었다면 화면가운데 흰색의 planeGeometry 가 보이게 될 것이다. GPGPU의 성능을 가장 잘 활용할 수 있는 영역은 아무래도 파티클 영역이기에 Mesh 를 Points 로 바꾸어준다.

const mesh = new THREE.Points(geometry, material)

바꾸어 적용하게 되면 메쉬 대신에 포인트 들로 바뀌어 보이게 될 것이다.

그리고 본격적으로 이 포인트 들을 변형하기 위해 MeshBasicMaterial 대신
ShaderMaterial을 적용한다.

const material = new THREE.ShaderMaterial(
{
vertexShader: vertexShader,
fragmentShader: fragmentShader
}
)

그리고 ShaderMaterial 에는 버텍스 쉐이더와 프래그먼트 쉐이더가 필요하고

//vertexshader
void main()
{
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
}
//fragmentshader
void main()
{
gl_FragColor = vec4(1.0,1.0,1.0,1.0);
}

각각 위와 같이 쉐이더를 만들고 재질(material)에 적용한다.
(기본적인 쉐이더를 이해한다고 가정하고 작성해 본다.)

여기서 쉐이더들이 잘 작동하는지 테스트해 본다면 버텍스 쉐이더에

void main()
{
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = 10.0 / -mvPosition.z; // 포인트 사이즈 추가
gl_Position = projectionMatrix * mvPosition;
}

포인트 사이즈를 추가해서 확인해본다.( gl_PointSize 는 포인트의 크기를 결정해준다라고 알면된다.)

적용하면 아까와 다르게 포인트들이 아까와 다르게 커져있는 것을 확인할 수 있다.

제대로 쉐이더 메터리얼이 적용되었고
이제 GPUComputationRenderer 를 본격적으로 사용해보자

import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js'
//.... 기존코드
this.gpuCompute = new GPUComputationRenderer(this.size, this.size, this.renderer)
//....기존코드

threejs 에서 잘 만들어놓은 모듈을 불러온 후 GPUComputationRenderer 를 하나 생성한후 현재 렌더러 사이즈에 맞게 초기화 합니다. 이제 GPGPU를 본격적으로 진행해 볼 차례입니다.

const gpuCompute = new GPUComputationRenderer(sizes.width, sizes.height, renderer)
const dtPosition = gpuCompute.createTexture();
const positionVariable = gpuCompute.addVariable('uCurrentPosition', simFragment, dtPosition )
gpuCompute.setVariableDependencies(positionVariable, [positionVariable])
gpuCompute.init()
//simFragment
void main() {
vec2 vUv = gl_FragCoord.xy / resolution.xy;
vec2 position = texture2D( uCurrentPosition, vUv ).xy;
gl_FragColor = vec4( position, 0.0, 1.0);
}

GPGPU 는 아까도 말했지만 GPU 가 연산을 하게 됩니다. 그래서 우리는CPU 연산과 달리 (GPU 가 연산에 사용할) shader에서 연산을 할 것이고 그 값등을 저장하는 텍스쳐가 필요하게 됩니다.

어쩌면 이부분이 마술이 이루어지는 핵심인 부분인 것 같습니다.
그리고 왜 연산에 필요한 값을 저장하는데 텍스쳐가 필요할까요?

이부분을 쉽게 이해하려면, 우리가 생각하는 텍스쳐에서 색을 구현할때 r,g,b,a 이렇게 픽셀당 4가지의 값을 저장할수 있는데 이를 x,y,z,w 값으로 바꾸어서 적용할수 있다고 생각하면 대강 어마어마하게 빨리 어떤 좌표를 바꿀수 있지않을까? 라고 생각되지않나요?

그리하여 gpuCompute.createTexture(); 로 데이터를 담을 텍스쳐를 만든 후
(여기서는 포지션값을 저장하기위해 dtPosition 이라고 네이밍하였습니다.)
계산에 사용될 positionVariable 를 만들어 줍니다.

positionVariable 의 코드를 찬찬히 살펴보면
simFragment 는 GPU 연산을 수행할 쉐이더(좌표 연산을 할 또 하나 새로 만든 프래그먼트 쉐이더) 이며 “uCurrentPosition” 은 쉐이더에서 연산에 사용될 변수이고 dtPosition 포지션은 아까 만든 데이터 텍스쳐 로서 GPU 연산의 기본 구성을 연결하는 부분이라고 볼 수 있습니다.

gpuCompute.setVariableDependencies(positionVariable, [positionVariable])

이 부분은 생성된 변수 positionVariable 이 positionVariable이 자신의 이전 상태에 기반하여 계산되어야 함을 정의하는 것으로, GPU에서 병렬 데이터 처리를 효율적으로 수행하기 위한 필수적인 설정입니다.(지금은 이렇게만 알아둡니다.)

이제 GPU연산을 위한 데이터 텍스쳐, 변수, 쉐이더프로그램까지 만들었으니 simFragmnet에서 연산된 값을 버텍스쉐이더에 전달하여 좌표가 실제로 변화하는지 테스트해보겠습니다.

먼저 simFragmnet 에서 연산된 값을 텍스쳐로 다시 최종 버텍스쉐이더 전달해야하기에 유니폼으로 uTexture 로 전달합니다.(처음에는 null로 적용하고)
onUpdate() 함수에서 데이터를 아래와 같이 전달합니다.

const material = new THREE.ShaderMaterial(
{
uniforms: {
uTexture:{ value: null },
},
vertexShader: vertexShader,
fragmentShader: fragmentShader
}
)
const onUpdate = () =>
{

controls.update()

gpuCompute.compute() // gpu연산 실행

material.uniforms.uTexture.value = gpuCompute.getCurrentRenderTarget(positionVariable).texture

// 데이터 텍스쳐에서 가져온 값들을 계속해서 버텍스 쉐이더에 uTexture 에전달

renderer.render(scene, camera)

window.requestAnimationFrame(onUpdate)
}

simFragment 에서는 변형될 데이터값을 변경한후

void main() {
vec2 vUv = gl_FragCoord.xy / resolution.xy;
vec2 position = texture2D( uCurrentPosition, vUv ).xy;

position.x += 0.001; // 데이터텍스쳐로 생성된 포지션 값의 X좌표 변형

gl_FragColor = vec4( position, 0.0, 1.0);
}

그후 버텍스 쉐이더에서 그값을 받아 좌표를 변형합니다.


uniform sampler2D uTexture;

void main()
{

vec3 newpos = position;
vec4 color = texture2D(uTexture, uv);

newpos.x += color.x;

vec4 mvPosition = modelViewMatrix * vec4(newpos, 1.0);
gl_PointSize = 2.0 / -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
}

위 과정이 끝나면 아래와 같이 이동하는 포인트들을 확인할 수 있습니다.

최종코드

import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js'
import GUI from 'lil-gui'
import vertexShader from './Shaders/vertexshader.glsl'
import fragmentShader from './Shaders/fragmentshader.glsl'
import simFragment from './Shaders/simulationfragment.glsl'


const gui = new GUI({ width: 340 })

const canvas = document.querySelector('canvas.webgl')

const scene = new THREE.Scene()

const geometry = new THREE.PlaneGeometry(2, 2, 128, 128)

const material = new THREE.ShaderMaterial(
{
uniforms: {
uTexture:{ value: null },
},
vertexShader: vertexShader,
fragmentShader: fragmentShader
}
)

const mesh = new THREE.Points(geometry, material)
//mesh.rotation.z = - Math.PI * 0.5
scene.add(mesh)


const sizes = {
width: window.innerWidth,
height: window.innerHeight
}

window.addEventListener('resize', () =>
{

sizes.width = window.innerWidth
sizes.height = window.innerHeight


camera.aspect = sizes.width / sizes.height
camera.updateProjectionMatrix()


renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})

const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.set(0, 0, 2)
scene.add(camera)


const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true


const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))


const clock = new THREE.Clock()

const gpuCompute = new GPUComputationRenderer(sizes.width, sizes.height, renderer)
const dtPosition = gpuCompute.createTexture();
const positionVariable = gpuCompute.addVariable('uCurrentPosition', simFragment, dtPosition )
gpuCompute.setVariableDependencies(positionVariable,[positionVariable])
gpuCompute.init()

const onUpdate = () =>
{

controls.update()

gpuCompute.compute()

material.uniforms.uTexture.value = gpuCompute.getCurrentRenderTarget(positionVariable).texture

renderer.render(scene, camera)

window.requestAnimationFrame(onUpdate)
}

onUpdate()

다음시간에는 좀 더 파티클스러운 형태를 활용한 GPGPU 활용 방식을 구현해보려고 한다.

미디어아트 & 인터렉티브 디자인 스튜디오

--

--