4.2 반응-확산
세 번째 예시로, 컴퓨트 셰이더와 렌더링 셰이더가 함께 작동하는 시나리오를 살펴보겠습니다. 컴퓨트 셰이더는 반응-확산(reaction-diffusion)이라고 알려진 간단한 시뮬레이션을 수행하고, 렌더링 셰이더는 이 시뮬레이션 결과물을 시각화하는 역할을 합니다.
앨런 튜링은 1952년 그의 선구적인 논문 "형태 형성의 화학적 기초(The Chemical Basis of Morphogenesis)"에서 생물학적 시스템, 특히 배아 발달 영역에서 복잡한 패턴과 구조가 나타나는 과정을 설명하기 위한 수학적 모델을 제안했습니다.
튜링의 모델은 반응 및 확산 과정을 겪는 두 가지 화학 물질의 상호 작용을 탐구합니다. 일반적으로 튜링 패턴(Turing pattern)이라고 불리는 이 개념은 반응-확산 시스템과 관련이 있습니다. 반응-확산 방정식은 화학 반응과 물질 확산에 의해 물질의 농도가 공간과 시간에 걸쳐 어떻게 진화하는지를 설명합니다. 패턴 형성 영역에서 튜링의 개념은 기본적인 국소 상호 작용이 생물학적 시스템 내에서 복잡한 패턴을 어떻게 생성할 수 있는지 이해하는 기초를 마련했습니다. 반응-확산 시스템의 겉보기에는 단순함에도 불구하고, 튜링은 이것이 얼룩말과 표범과 같은 동물에서 관찰되는 복잡한 패턴의 근본 원리라고 이론화했습니다.
우리의 시뮬레이션에서는 두 가지 화학 물질인 A와 B의 행동을 시뮬레이션합니다. 특히, 두 개의 B 분자가 한 개의 A 분자를 B로 변환하는 것을 촉매합니다. 화학 물질 A는 지정된 속도로 시스템에 유입되고, 화학 물질 B는 다른 속도로 시스템에서 제거됩니다. 화학 물질 A의 추가와 화학 물질 B의 제거 사이의 이러한 역동적인 상호 작용은 시뮬레이션의 진화하는 상태에 기여하며, 시뮬레이션된 반응-확산 시스템 내의 복잡한 균형과 상호 작용을 반영합니다.
그러나 우리는 개별 분자를 모델링하고 싶지 않습니다. 대신 전체 공간을 작은 단위로 나누고, 각 단위는 픽셀을 나타냅니다. 각 시간 단계 간격마다 각 픽셀의 A와 B의 양을 업데이트하며, 그 농도는 색상으로 시각화됩니다. 전체 과정은 다음 방정식으로 설명할 수 있습니다:
\begin{aligned}
A^\prime &= A + (D_A \nabla^2 A - A B^2 + f(1-A) )\Delta t \\
B^\prime &= B + (D_B \nabla^2 B + A B^2 - (K+f)B)\Delta t
\end{aligned}
이 첫 번째 방정식은 화학 물질 A의 동작을 모델링합니다. 각 단위 시간 \Delta t에 대해, 확산된 양은 D_A \nabla^2 A로 정의되며, 여기서 D_A \nabla^2는 라플라시안이라고 불립니다. 라플라시안은 기본적으로 각 3x3 이웃의 가중 합계를 계산하는 데 사용되는 3x3 행렬입니다. 중앙 픽셀이 이웃보다 농도가 높으면 더 많은 화학 물질이 이웃 픽셀로 흐르도록 허용합니다. 반대로 이웃의 농도가 높으면 화학 물질이 이웃에서 중앙 픽셀로 흐릅니다.
방정식의 두 번째 부분은 화학 반응을 모델링합니다. 반응이 일어나려면 두 분자의 B와 한 분자의 A가 필요합니다. 화학 물질을 농도로 모델링하기 때문에, 이를 분자와 마주칠 확률로 해석할 수 있습니다. 이는 화학 물질의 농도가 높을수록 분자와 마주칠 가능성이 커지기 때문에 타당합니다. 따라서 A B^2는 하나의 A 분자가 두 개의 B 분자와 마주칠 확률을 나타냅니다.
마지막으로, 우리는 f(1-A)의 속도로 새로운 A 분자를 시스템에 추가합니다. 이는 A의 농도가 1.0을 초과하지 않도록 보장합니다. 왜냐하면 만약 1.0을 초과하면 속도가 음수가 되어 효과를 상쇄시키기 때문입니다.
유사하게, 우리는 (K+f)B의 속도로 B 분자를 제거합니다. K + f > f이므로, 이는 B가 A가 추가되는 것보다 빠르게 제거되도록 보장합니다.
\Delta t는 시간 간격입니다.
이제 코드를 살펴보겠습니다. 이 연습의 목적은 컴퓨트 셰이더가 렌더링 셰이더와 함께 작동하는 방식을 보여주는 것입니다. 프로그램에는 두 개의 스토리지 텍스처가 사용됩니다. 일반 텍스처와 달리 스토리지 텍스처는 쓰기 작업이 가능합니다. 우리는 두 개의 텍스처를 사용합니다. 각 셰이더 호출에 대해 하나의 텍스처는 현재 화학 농도를 포함하는 입력으로 사용됩니다. 우리는 방정식에 따라 농도를 업데이트하고 그 결과를 다른 스토리지 텍스처에 씁니다. 그런 다음 시각화를 위해 렌더링 프로그램을 실행합니다. 연속적인 실행에서는 두 텍스처의 역할을 전환합니다. 이전 입력 텍스처가 출력이 되고 그 반대도 마찬가지입니다.
enable chromium_experimental_read_write_storage_texture;
@binding(0) @group(0) var texSrc : texture_storage_2d;
@binding(1) @group(0) var texDst : texture_storage_2d;
@binding(2) @group(0) var drawPos : vec3;
const imageSize:i32 = 1024;
먼저, 컴퓨트 셰이더의 입력을 정의하겠습니다. 첫 줄에서 쓰기 가능한 스토리지 텍스처 기능을 명시적으로 활성화해야 합니다. 이 기능은 아직 실험적이기 때문입니다. 이 기능을 활성화하는 데 필요한 단계는 이것뿐만이 아닙니다. 장치를 초기화할 때도 이 기능을 요청해야 하며, 이는 나중에 살펴보겠습니다.
앞서 언급했듯이, 입력용과 출력용으로 두 개의 텍스처 맵이 있습니다. 두 텍스처 맵의 형식은 rg32f입니다. 두 가지 유형의 화학 물질이 있으므로 두 채널을 사용하여 이를 나타냅니다. 숫자 정확도를 위해 32f를 선택합니다.
또한 위치를 정의하는 drawPos가 있습니다. 이 위치는 현재 브러시 위치를 나타냅니다. 데모를 상호작용적으로 만들고 싶기 때문에, 사용자가 브러시 위치에 화학 물질 A를 추가하여 캔버스에 그릴 수 있도록 허용합니다.
이미지 크기는 1024로 하드코딩되어 있습니다.
@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) GlobalInvocationID : vec3) {
var centerY:i32 = i32(GlobalInvocationID.y);
var centerX:i32 = i32(GlobalInvocationID.x);
if (drawPos.z > 0.5) {
var radius:f32 = sqrt((f32(centerX) - drawPos.x)*(f32(centerX) - drawPos.x) +
(f32(centerY) - drawPos.y)*(f32(centerY) - drawPos.y));
if (radius < 5.0) {
textureStore(texDst, vec2i(GlobalInvocationID.xy), vec4(0.0,1.0,0.0,0.0));
return;
}
}
}
다음으로, 컴퓨트 커널 함수를 생성합니다. 16x16 크기의 2D 워크그룹을 사용하고 있음에 유의하십시오. 2D 워크그룹은 우리의 문제가 2D 텍스처 맵의 픽셀 값을 계산하는 것을 포함하기 때문에 적합합니다. 16x16이 1024의 텍스처 맵 크기에 비해 작아 보일 수 있지만, 이 크기는 우리가 요청할 수 있는 최대 워크그룹 크기입니다. 이는 maxComputeInvocationsPerWorkgroup 제한 때문이며, 이는 256입니다 (컴퓨트 단계의 워크그룹 차원 곱의 최대 값).
다음으로, 브러시 입력을 처리합니다. 사용자가 활발하게 그림을 그리고 있다면(drawPos.z > 0.5로 판단), 현재 픽셀(centerX, centerY)과 drawPos.xy 사이의 거리를 계산합니다. 이 거리가 5.0보다 작으면 텍스처 색상을 (0.0, 1.0)으로 설정하여 픽셀이 분자 B로 완전히 농축되었음을 나타냅니다.
for(var y = -1; y<= 1;y++) {
var accessY:i32 = centerY + y;
if (accessY < 0) {
accessY = imageSize-1;
} else if (accessY >= imageSize) {
accessY = 0;
}
for(var x = -1; x<=1;x++) {
var accessX:i32 =centerX+ x;
if (accessX < 0) {
accessX = imageSize-1;
} else if (accessX >= imageSize) {
accessX = 0;
}
var rate = -1.0;
if (x==0 && y == 0) {
rate = -1.0;
} else if (x ==0 || y==0) {
rate = 0.2;
} else {
rate = 0.05;
}
data += textureLoad(texSrc, vec2i(accessX, accessY)).rg * rate;
}
}
여기서 현재 픽셀을 둘러싼 3x3 이웃을 반복합니다. 인덱스가 이미지 경계를 벗어나면 이미지의 반대쪽 가장자리로 래핑되도록 합니다.
var rate = -1.0;
if (x==0 && y == 0) {
rate = -1.0;
} else if (x ==0 || y==0) {
rate = 0.2;
} else {
rate = 0.05;
}
data += textureLoad(texSrc, vec2i(accessX, accessY)).rg * rate;
그런 다음 확산으로 인한 농도 변화를 계산합니다. `rate` 변수는 각 이웃 픽셀이 중심 픽셀로부터의 거리에 따라 얼마나 영향을 미치는지 결정합니다.
const f:f32 = 0.055;
const k:f32 = 0.062;
data = vec2(data.x- original.x*original.y*original.y
+ f * (1.0 - original.x),
data.y*0.5 + original.x*original.y*original.y - (k+f) * original.y) + original;
textureStore(texDst, vec2i(GlobalInvocationID.xy), vec4(data,0.0,0.0));
마지막으로, 반응으로 인한 농도 변화를 계산하고 그에 따라 시스템을 업데이트합니다. 결과는 출력 텍스처 맵에 기록됩니다.
시각화를 위해, 렌더링 셰이더는 기본 텍스처 매핑 셰이더와 유사하게 작동합니다. 컴퓨트 셰이더의 결과를 쿼드에 매핑하여 표시합니다. 렌더링 셰이더의 경우 사용되는 텍스처는 스토리지 텍스처일 필요가 없으며, 표준 텍스처 맵 유형으로 충분합니다.
이제 파이프라인 구성을 살펴보겠습니다.
const feature = "chromium-experimental-read-write-storage-texture";
const adapter = await navigator.gpu.requestAdapter();
console.log(adapter);
if (!adapter.features.has(feature)) {
showWarning("This sample requires a chrome experimental feature. Please restart chrome with the --enable-dawn-features=allow_unsafe_apis flag.");
throw new Error("Read-write storage texture support is not available");
}
const device = await adapter.requestDevice({
requiredFeatures: [feature],
});
첫째, 앞서 언급했듯이 GPU 장치를 생성할 때 스토리지 텍스처 기능에 대한 지원을 요청하는 것이 필수적입니다.
다음으로, 컴퓨트 단계의 유니폼을 다음과 같이 구성합니다:
const context = configContext(device, canvas)
// create shaders
let shaderModule = shaderModuleFromCode(device, 'shader');
let uniformBindGroupLayoutCompute = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
access: "read-only",
format: "rg32float",
}
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
access: "write-only",
format: "rg32float",
}
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: {}
}
]
});
이 구성은 이전 설정과 유사하며, 특히 스토리지 텍스처의 사양에 주의를 기울입니다. 입력 텍스처는 읽기 전용으로 지정되고, 출력 텍스처는 쓰기 전용으로 설정됩니다. 사용되는 형식은 화학 농도에 충분한 정밀도를 보장하는 rg32float입니다.
다음으로, 입력 텍스처를 초기화합니다. 이 단계는 텍스처에 초기 화학 농도를 할당하는 데 중요합니다. 이를 통해 사용자 상호 작용이 없더라도 시뮬레이션이 자율적으로 패턴을 생성할 수 있습니다. 이 설정은 시스템이 미리 정의된 초기 조건에 따라 패턴을 개발하고 진화하도록 허용하여 반응-확산 과정의 의미 있는 시연을 용이하게 합니다.
const srcTextureDesc = {
size: [1024, 1024, 1],
dimension: '2d',
format: "rg32float",
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
};
let textureValues = [];
for (let y = 0; y < 1024; ++y) {
for (let x = 0; x < 1024; ++x) {
if (x > 302 && x < 532 && y >= 302 && y < 532) {
textureValues.push(0.0);
textureValues.push(1.0);
}
else {
textureValues.push(1.0);
textureValues.push(0.0);
}
}
}
let srcTexture = device.createTexture(srcTextureDesc);
device.queue.writeTexture({ texture: srcTexture }, new Float32Array(textureValues), {
offset: 0,
bytesPerRow: 1024 * 8,
rowsPerImage: 1024
}, { width: 1024, height: 1024 });
await device.queue.onSubmittedWorkDone();
텍스처 맵을 초기화하기 위해, 우리는 화학 물질 B가 할당되는 직사각형 영역 [302, 532, 302, 532]를 정의하고, 이 직사각형 외부에는 화학 물질 A가 사용됩니다. 이 설정은 간단하며 시뮬레이션의 초기 조건이 잘 정의되도록 보장합니다. 결과 데이터는 입력 텍스처로 로드됩니다.
const dstTextureDesc = {
size: [1024, 1024, 1],
dimension: '2d',
format: "rg32float",
usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
};
let dstTexture = device.createTexture(dstTextureDesc);
let drawPosUniformBuffer = createGPUBuffer(device, drawPos, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
다음으로, 초기화가 필요 없는 출력 텍스처를 생성합니다. 연속적인 호출 간에 입력 및 출력 텍스처의 교대 역할을 관리하기 위해 두 개의 유니폼 바인드 그룹을 설정합니다:
let uniformBindGroupCompute0 = device.createBindGroup({
layout: uniformBindGroupLayoutCompute,
entries: [
{
binding: 0,
resource: srcTexture.createView()
},
{
binding: 1,
resource: dstTexture.createView()
},
{
binding: 2,
resource: {
buffer: drawPosUniformBuffer
}
}
]
});
let uniformBindGroupCompute1 = device.createBindGroup({
layout: uniformBindGroupLayoutCompute,
entries: [
{
binding: 0,
resource: dstTexture.createView()
},
{
binding: 1,
resource: srcTexture.createView()
},
{
binding: 2,
resource: {
buffer: drawPosUniformBuffer
}
}
]
});
이어서, 컴퓨트 셰이더 파이프라인을 설정해야 합니다. 이 과정은 이전 예시들과 유사하므로 코드는 생략합니다. 컴퓨트 셰이더가 구성되면, 렌더링 셰이더의 바인드 그룹 레이아웃을 구성합니다:
const sampler = device.createSampler({
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
addressModeW: "clamp-to-edge",
magFilter: "nearest",
minFilter: "nearest",
mipmapFilter: "nearest"
});
let uniformBindGroupLayoutRender = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
texture: {
sampleType: "unfilterable-float",
}
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: "non-filtering",
}
}
]
});
"float32-filterable" 기능이 활성화되지 않는 한, 텍스처 형식 rg32float는 sampleType으로 "unfilterable-float"만 사용할 수 있습니다. 결과적으로 샘플러는 필터링에 "nearest"를 사용해야 하며 "non-filtering" 유형이어야 합니다. 컴퓨터 그래픽에서 필터링은 셰이더 프로그램에서 읽히는 값을 결정하기 위해 텍스처 맵에 정의된 값을 보간하는 것을 포함합니다. 텍스처 매핑 중에는 텍스처 조회를 통해 각 픽셀 중심이 텍스처의 어디에 해당하는지 결정합니다. 3D 게임 및 영화에서 흔히 볼 수 있는 텍스처 매핑된 다각형 표면에서는 각 픽셀(또는 하위 픽셀 샘플)이 일부 삼각형과 barycentric 좌표 집합에 해당하며, 이는 텍스처 내에서 해당 위치를 정의합니다. 텍스처가 적용된 표면이 뷰어에 대해 임의의 거리 및 방향에 있을 수 있으므로, 하나의 픽셀이 일반적으로 하나의 텍셀과 직접 정렬되지 않습니다. 픽셀에 가장 적합한 색상을 결정하고 블록화, 앨리어싱 또는 깜박임과 같은 아티팩트를 방지하려면 필터링이 필요합니다.
화면에 표시되는 픽셀과 텍셀 간의 관계는 텍스처가 적용된 표면의 뷰어에 대한 상대적 위치에 따라 달라집니다. 예를 들어, 사각형 텍스처가 사각형 표면에 매핑되고 뷰어 거리가 한 화면 픽셀이 정확히 하나의 텍셀과 동일한 크기가 되도록 하는 경우, 필터링이 필요하지 않습니다. 그러나 텍셀이 화면 픽셀보다 크거나(텍스처 확대), 각 텍셀이 픽셀보다 작은 경우(텍스처 축소), 적절한 필터링이 필요합니다. OpenGL과 같은 그래픽 API는 프로그래머가 확대 및 축소에 대해 다른 필터를 설정할 수 있도록 합니다.
픽셀과 텍셀의 크기가 같더라도 픽셀이 텍셀과 완벽하게 정렬되지 않을 수 있으며 최대 4개의 인접 텍셀의 일부를 덮을 수 있습니다. 따라서 필터링은 여전히 필요합니다.
우리의 데모에서는 "float32-filterable"을 활성화하는 추가 요구 사항을 건너뛰고 보간 없이 텍스처 맵에서 값을 직접 읽습니다.
바인드 그룹의 경우, 두 개의 텍스처 맵을 번갈아 사용하기 위해 두 개의 별도 그룹을 생성해야 합니다:
let uniformBindGroupRender0 = device.createBindGroup({
layout: uniformBindGroupLayoutRender,
entries: [
{
binding: 0,
resource: dstTexture.createView()
},
{
binding: 1,
resource: sampler
}
]
});
let uniformBindGroupRender1 = device.createBindGroup({
layout: uniformBindGroupLayoutRender,
entries: [
{
binding: 0,
resource: srcTexture.createView()
},
{
binding: 1,
resource: sampler
}
]
});
렌더링 파이프라인을 설정하는 나머지 코드는 이전 예시들과 유사하므로 자세한 내용은 생략하겠습니다. 이제 브러시 그리기 구현에 대해 알아보겠습니다. 반응-확산 과정에 의해 생성되는 패턴은 초기 화학 농도에 민감하므로, 패턴을 수정하기 위해 사용자가 이 농도를 변경할 수 있도록 해야 합니다. 이를 위해 브러시 그리기 기능을 추가합니다.
다음은 브러시 그리기를 위한 마우스 입력 처리 방법입니다:
let prevX = 0.0;
let prevY = 0.0;
let isDragging = false;
canvas.onmousedown = (event) => {
var rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
isDragging = true;
}
canvas.onmousemove = (event) => {
if (isDragging != 0) {
var rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
drawPos.set([x, y, 1.0], 0);
}
}
canvas.onmouseup = (event) => {
isDragging = 0;
drawPos.set([0.0, 0.0, 0.0], 0);
}
구현은 간단합니다: 사용자가 그림을 그릴 때는 유니폼 `drawPos`를 (x, y, 1.0)으로 업데이트하고, 그렇지 않을 때는 (0.0, 0.0, 0.0)으로 설정합니다. 셰이더 코드에서는 이 벡터의 세 번째 구성 요소(drawPos.z > 0.5)를 사용하여 브러시가 캔버스에 활발하게 닿아 있는지 확인합니다.
const passEncoder = commandEncoder.beginComputePass(
{}
);
passEncoder.setPipeline(computePipeline);
if (frame % 2 == 0) {
passEncoder.setBindGroup(0, uniformBindGroupCompute0);
}
else {
passEncoder.setBindGroup(0, uniformBindGroupCompute1);
}
passEncoder.dispatchWorkgroups(64, 64);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
const commandEncoder2 = device.createCommandEncoder();
const passEncoder2 = commandEncoder2.beginRenderPass(renderPassDesc);
passEncoder2.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder2.setPipeline(renderPipeline);
if (frame % 2 == 0) {
passEncoder2.setBindGroup(0, uniformBindGroupRender0);
}
else {
passEncoder2.setBindGroup(0, uniformBindGroupRender1);
}
passEncoder2.setVertexBuffer(0, positionBuffer);
passEncoder2.draw(4, 1);
passEncoder2.end();
렌더링 프로세스는 두 개의 개별 패스로 구성됩니다. 첫 번째 패스는 컴퓨트 작업을 수행하고 출력 텍스처 맵을 업데이트하며, 두 번째 패스는 업데이트된 텍스처 맵을 화면에 렌더링합니다. 컴퓨트 셰이더는 16x16의 워크그룹 크기를 사용하며, 우리는 64x64의 2D 형태로 워크그룹을 디스패치합니다. 이는 64x16이 1024와 같고, 이는 우리 텍스처의 크기와 일치하기 때문입니다.
이것으로 한 프레임의 렌더링이 완료됩니다. 다음 프레임에서는 입력 및 출력 텍스처의 역할을 전환합니다: 출력 텍스처는 입력이 되고, 입력 텍스처는 출력이 됩니다.
