voro-noise

노이즈는 렌덤을 기반으로 하며 FBM(Fractal Brownian Motion)을 위한 한 옥타브를 구성합니다. 몇가지 노이즈 중 하나로 보로노이(Voronoi)를 기반으로 하는 voro-noise가 있는데, Shader의 대가인 Inigo님이 2014년에 만든 알고리즘입니다. 내부 코드를 보면 퍼퍼먼스에 다소 부정적인 부분(일반적인 9개의 격자 그리드가 아닌 25개의 격자 그리드를 사용)이 보이지만 그 결과는 여타 다른 노이즈보다 훨씬 뛰어납니다.

아래의 코드는 voro-noise에 대한 구현 코드입니다.

vec3 hash3(vec2 p) {
    vec3 q = vec3(
        dot(p, vec2(127.1, 311.7)), 
        dot(p, vec2(269.5, 183.3)), 
        dot(p, vec2(419.2, 371.9))
    );
    return fract(sin(q) * 43758.5453);
}

float voronoise(in vec2 p, float u, float v) {
    float k = 1.0 + 63.0 * pow(1.0 - v, 6.0);

    vec2 i = floor(p);
    vec2 f = fract(p);

    vec2 a = vec2(0.0, 0.0);
    for(int y = -2; y <= 2; y++) {
        for(int x = -2; x <= 2; x++) {
            vec2 g = vec2(x, y);
            vec3 o = hash3(i + g) * vec3(u, u, 1.0);
            vec2 d = g - f + o.xy;
            float w = pow(1.0 - smoothstep(0.0, 1.414, length(d)), k);
            a += vec2(o.z * w, w);
        }
    }

    return a.x / a.y;
}

이 함수에 대한 가장 흔한 코드 예시는 다음과 같습니다.

void main() {
    vec2 st = gl_FragCoord.xy / uResolution.xy;
    st.x *= uResolution.x / uResolution.y;
    st *= 10.0;

    float f = voronoise(st, 1., 1.);
    gl_FragColor = vec4(f, f, f, 1.0);
}

위의 코드의 결과는 다음과 같구요.

voronoise 함수는 3개의 인자를 받는데, 첫번째 인자는 일반적으로 노이즈 함수가 받는 인자입니다. 2,3번째 인자인 u와 v는 voronoise에 특화된 인자인데 u는 노이즈 생성을 격자 그리드(Grid)를 얼마나 보로노이스럽게 표현할지에 대한 강도값으로 0에서 1까지의 값을 갖습니다. v는 격자 그리드 내부를 채우는 각 프레그먼트에 대한 보간 정도에 대한 강도 값인데 0부터 1의 값이며 0일때 전혀 보간이 이루어지지 않고 1일때 최대의 보간이 이뤄집니다. 아래의 결과는 u와 v를 모두 0으로 했을 때의 결과입니다.

아래는 u를 1로 v는 0으로 했을때의 결과입니다.

마지막으로 아래는 u를 0으로 v를 1로 했을때의 결과입니다.

아래는 노이즈 구현에 대한 유용한 사이트입니다.

https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83

WebGPU를 이용한 삼각형 그리기

WebGPU는 GPU를 이용해 그래픽을 렌더링하거나 범용적인 연산을 실행할 수 있는 웹 API입니다. 이 글은 WebGPU를 이용해 간단한 삼각형을 렌더링하는 코드를 살펴봅니다.

WebGL과 마찬가지로 그래픽을 출력할 Canvas가 필요합니다. HTML 파일에 Canvas 요소를 추가해야 합니다. 추가했다고 치고…

WebGPU의 초기화가 필요한데, WebGPU의 초기화는 비동기적으로 실행되므로 별도의 비동기 함수로 처리합니다.

async function main() {
  // 앞으로 코드는 모두 여기에 추가됨
}

await main();

먼저 GPU 디바이스 객체를 얻어옵니다.

  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();

이제 이 device 객체를 통해 그래픽을 렌더링할텐데, 아래의 코드를 통해 렌더링 결과가 출력된 캔버스와 연결해야 합니다.

  const canvas = document.querySelector('canvas');
  const context = canvas.getContext('webgpu');
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
  context.configure({
    device,
    format: presentationFormat,
  });

WebGL과 마찬가지로 WebGPU 역시 Shader 코드가 필요합니다. 하지만 쉐이더 언어가 다르며 WebGL은 GLSL이고 WebGPU는 WGSL(위그실)입니다. WebGL에서는 Vertex Shader와 Fragment Shader가 필요했던 것처럼 WebGPU 역시도 마찬가지입니다. 해당 코드는 다음과 같습니다.

  const module = device.createShaderModule({
    label: 'our hardcoded red triangle shaders',
    code: /* wgsl */ `
      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> @builtin(position) vec4f {
        let pos = array(
          vec3f(   0,  .5, 0), 
          vec3f( -.5, -.5, 0), 
          vec3f(  .5, -.5, 0)  
        );
 
        return vec4f(pos[vertexIndex], 1.0);
      }
 
      @fragment fn fs() -> @location(0) vec4f {
        return vec4f(1.0, 0.0, 0.0, 1.0);
      }
    `,
  });

이제 쉐이더 코드를 실행할 렌더 파이프 라인 객체를 생성합니다.

  const pipeline = device.createRenderPipeline({
    label: 'our hardcoded red triangle pipeline',
    layout: 'auto',
    vertex: {
      module,
      entryPoint: 'vs',
    },
    fragment: {
      module,
      entryPoint: 'fs',
      targets: [{ format: presentationFormat }],
    },
  });

다음은 실제 렌더링을 위해 필요한 설정값(Canvas의 텍스쳐뷰, 배경색상 등)을 갖는 디스크립터 객체를 생성합니다.

  const renderPassDescriptor = {
    label: 'our basic canvas renderPass',
    colorAttachments: [
      {
        view: context.getCurrentTexture().createView(),
        clearValue: [0.3, 0.3, 0.3, 1],
        loadOp: 'clear',
        storeOp: 'store',
      },
    ],
  };  

이제 실제 렌더링을 위한 구체적인 명령을 인코딩하기 위한 객체를 생성하고 렌더링 명령을 지정합니다.

  const encoder = device.createCommandEncoder({ label: 'our encoder' });

  const pass = encoder.beginRenderPass(renderPassDescriptor);
  pass.setPipeline(pipeline);
  pass.draw(3);  // 정점 셰이더를 3번 호출
  pass.end();

위의 코드는 명령을 지정만 했을 뿐이고 실제 실행을 위한 코드는 다음과 같습니다.

  const commandBuffer = encoder.finish();
  device.queue.submit([commandBuffer]);

Vite React(또는 바닐라), shader 코드를 별도의 파일로 분리하기

이를 위해서는 vite가 쉐이더 코드를 이해하는 척 해야 하는데, 이를 위해 다음 패키지를 설치합니다.

npm i -D vite-plugin-glsl

그리고 vite.config.js(파일이 없으면 직접 생성해야 함)의 코드를 수정합니다. 기존 코드에서 변경된 부분은 3번과 7번 줄입니다.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import glsl from "vite-plugin-glsl"

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), glsl()],
})

이제 다음처럼 별도의 파일로 지정한 쉐이더 코드를 import할 수 있고 사용할 수 있습니다.

import { shaderMaterial, useGLTF } from '@react-three/drei'
import { extend } from '@react-three/fiber'

import stripesVertex from "./stripes.vertex.glsl"
import stripesFragment from "./stripes.fragment.glsl"

const StripesShaderShaderMaterial = shaderMaterial(
  {},
  stripesVertex,
  stripesFragment
)

extend({ StripesShaderShaderMaterial })

GLSL 코드와 Blender의 쉐이더 노드

쉐이더 프로그래밍 언어 중에 하나인 GLSL의 프레그먼트 쉐이더 코드를 블렌더의 쉐이더 노드로 구성하는 내용에 대한 정리입니다. 먼저 GLSL에 대한 코드는 다음과 같습니다.

varying vec2 vUv;

void main() {
  float strength = step(0.01, abs(distance(vUv, vec2(0.5)) - 0.3));
  gl_FragColor = vec4(vec3(strength), 1.0);
}

결과는 다음과 같습니다. three.js를 사용했습니다.

위의 쉐이더 코드를 블렌더의 쉐이더 노드로 구성하면 다음과 같습니다.

쉐이더의 코드는 블렌더의 쉐이더 노드로 구성할 수 있고, 그 반대로도 가능합니다. 쉐이더 코드는 매우 함축적이고 작은 코드 변화에도 그 결과는 예상하기 어려운 경우가 있습니다. 하지만 블렌더의 쉐이더 노드를 통해 먼저 그 결과를 만들고 이를 다시 쉐이더 코드로 변환한다면 좀 더 나은 개발 접근이 될 수 있습니다.

dFdx와 dFdy를 이용한 법선 벡터 계산

버텍스 쉐이더에서 정점을 흔들었을 경우 법선 벡터 역시 다시 계산을 해줘야 하는데, 이때 정점의 x와 y에 대한 편미분 값을 얻을 수 있는 dFdx와 dFdy를 이용하면 법선 벡터를 얻을 수 있습니다.

이런 경우 버텍스 쉐이더에 전달된 normal을 vary를 통해 프레그먼트 쉐이더로 전달할 필요가 없고 프레그먼트 쉐이더에서 법선 벡터를 계산해 주면 되는데, 프레그먼트 쉐이더에서 이에 대한 코드는 다음과 같습니다.

vec3 normal = normalize(
    cross(
        dFdx(vPosition.xyz),
        dFdy(vPosition.xyz)
    )
);

위의 vPosition은 버텍스 쉐이이더에서 재계산된 정점인데, 버텍스 쉐이더의 코드에서 보면 다음과 같습니다.

varying vec3 vPosition;

void main() {	
    vec3 posClone = position;
    posClone = /* 정점 흔들기(변경) */
    
    ...

    vPosition = modelMatrix * vec4(posClone, 1.0)).xyz;

    ...
}

적용 결과로 비교하면 먼저 dFdx와 dFdy를 통한 법선 벡터를 사용하지 않고 지오메트리를 통해 제공되는 법선 벡터를 그대로 사용한 경우는 아래와 같습니다.

버텍스 쉐이더에서 정점을 흔들어서 원래 제공된 법선 벡터가 맞지 않아 음영 효과가 제대로 표현되지 않는데, 이를 개선하기 위해 dFdx와 dFdy를 통한 법선 벡터를 사용한 결과는 다음과 같습니다.