5.4 메가 텍스처

가상화된 텍스처라고도 하는 메가 텍스처는 가상 메모리와 같은 컴퓨터 과학의 가상화 개념에서 영감을 얻었습니다. 가상 메모리는 필요한 부분만 동적으로 로드함으로써 애플리케이션이 물리적으로 사용 가능한 메모리보다 더 많은 메모리를 사용할 수 있도록 합니다. 유사하게, 가상화된 텍스처는 비디오 메모리 용량을 초과하는 텍스처를 관리할 수 있도록 합니다.

플레이그라운드 실행 - 5_04_mega_texture

그 중요성을 이해하기 위해 이미지 데이터의 크기를 고려해 봅시다. 예를 들어, 1024x1024 RGBA 이미지는 비디오 메모리에서 4MB를 차지합니다. 8GB 메모리를 가진 비디오 카드는 최대 2000개의 이러한 이미지를 저장할 수 있습니다. 그러나 비디오 메모리는 다른 리소스와 공유되어야 하므로 실제 텍스처 맵의 수는 더욱 제한적입니다. 큰 게임 장면에서는 이러한 제한이 쉽게 소진될 수 있습니다.

또한, 단일 텍스처 맵의 최대 크기 제한이 있습니다. WebGPU 사양에 따르면, 이는 2D 텍스처의 경우 `maxTextureDimension2D`로 질의할 수 있으며, 기본값은 8192입니다. 만약 이 크기보다 큰 단일 이미지가 있다면, 메가 텍스처와 같은 기술이 필요합니다.

게다가, 우리는 항상 텍스처의 최고 해상도 버전을 로드할 필요는 없습니다. 텍스처가 적용된 표면이 멀리서 보일 때는 낮은 해상도 버전으로도 샘플링 문제를 해결하기에 충분합니다. 이 개념은 밉맵으로 다루어집니다. 그러나 밉맵을 순수하게 사용하면 원본 이미지와 더 낮은 해상도 이미지의 피라미드가 메모리에 로드되므로 메모리 사용량이 증가합니다.

메가 텍스처를 사용하면 합쳐진 크기가 비디오 메모리 용량을 초과하는 대규모 텍스처 맵 풀을 다룰 수 있습니다. 이 과정은 두 번의 패스로 이루어집니다. 첫 번째 패스에서는 실제 텍스처 맵을 사용하는 대신, 사전 렌더링 단계에서 어떤 텍스처가 어떤 세부 수준으로 보여야 하는지 결정합니다. 그러면 CPU는 보이는 텍스처를 타일링하여 단일 텍스처 맵을 조립하고, 룩업 테이블을 만듭니다. 이 테이블은 텍스처 ID와 세부 수준에 따라 조립된 텍스처에서 해당 타일의 위치를 찾는 데 사용됩니다. 두 번째 렌더링 패스에서는 텍스처 가시성이 재계산되고, 룩업 테이블을 사용하여 적절한 타일을 샘플링합니다.

이 접근 방식은 조립된 텍스처 맵이 보이는 최대 수의 텍스처를 담을 수 있는 한, 전체 텍스처 맵 수에 대한 제한을 제거합니다.

조립된 텍스처 맵은 캐시처럼 관리되어야 합니다. 타일이 이미 존재하면 다시 로드해서는 안 되며, 룩업 테이블의 해당 항목도 업데이트해서는 안 됩니다. 조립된 텍스처 맵이 가득 차면, 가장 적게 사용된 타일이 교체됩니다. 목표는 조립된 텍스처 맵의 업데이트를 최소화하는 것입니다.

이 예제에서는 메가 텍스처의 단순화된 경우를 살펴봅니다. 제임스 웹 우주 망원경으로 촬영된 게 성운과 같은 대형 텍스처를 표시하기 위해 화면에 정렬된 평면을 렌더링합니다. 원본 이미지 크기는 10752x9216으로 129.3MB를 차지하며, 이는 단일 텍스처 맵에 로드하기에는 너무 큽니다. 비록 이것이 특수한 경우이지만, 메가 텍스처는 표면이 화면에 정렬되지 않은 더 일반적인 시나리오에도 적용될 수 있습니다. 이 데모는 또한 Google 지도와 유사하게 대형 이미지를 로드하고 탐색하는 방법을 보여줍니다.

원본 이미지가 너무 크기 때문에 한 번에 모두 로드하지 않습니다. 대신, 전처리 프로그램을 사용하여 256x256 타일로 분할합니다. 다양한 세부 수준을 달성하기 위해 전처리기는 이미지의 거친 버전도 생성하며, 모든 타일을 256x256으로 유지하여 단순성을 유지합니다. 예를 들어, 네 개의 256x256 타일은 하나의 512x512 타일을 형성한 다음, 더 거친 수준을 위해 256x256으로 축소됩니다.

다음은 Python으로 작성된 타일 전처리기 코드입니다:

import PIL.Image
import math
image = PIL.Image.open('crab_nebula.png')
# Round the image width and height to multiples of 256.
image = image.crop((0, 0, math.ceil(image.width/256)*256,math.ceil(image.height/ 256)*256))
# Add a 2-pixel padding on tile edges.
padding = 2
# Each level halve the width/height, 
# hence we need log(256) levels to shrink a tile into a single pixel.
for lv in range(0,math.floor(math.log(256,2))+1):
    # How much we need to resize the original image for this level.
    resize_ratio = math.pow(2, lv)
    scaled_width = int(image.width / resize_ratio)
    scaled_height = int(image.height / resize_ratio)
    resized_image = image.resize((scaled_width, scaled_height))
    # h,v are the horizontal and vertical tile counts.
    h = math.ceil(scaled_width / 256)
    v = math.ceil(scaled_height / 256)
    for y in range(0,v):
        for x in range(0,h):
            # Crop a tile, the size of a tile is 260x260 including the padding.
            cropped_image = resized_image.crop((x*256-padding, y*256-padding, (x+1)*256+padding,(y+1)* 256+padding))
            # Save the tile into a png file.
            cropped_image.save('../crab_nebula/crab_'+str(lv)+'_'+str(y)+'_'+str(x)+'.png')

코드에서 쉽게 로드할 수 있도록 타일 이름을 crab___ 패턴으로 지정합니다. 여기서 level은 세부 수준을 나타내고, y는 동일한 수준의 모든 타일 중 수직 인덱스이며, x는 수평 인덱스입니다.

또한, 모든 타일에 패딩이 추가되어 실제 크기는 각 차원마다 256보다 4픽셀 더 커집니다. 이 패딩의 필요성은 나중에 설명하겠습니다.

다양한 수준의 타일
다양한 수준의 타일

먼저, 몇 가지 상수를 설정해 봅시다. 관련 코드는 다음과 같습니다:

const imageWidth = 10752;
const imageHeight = 9216;
const tileSizeWithoutPadding = 256;
const textureSizeWithoutPadding = 2048;
const padding = 2;
const maxVisibleTileCountOnTexture = textureSizeWithoutPadding * textureSizeWithoutPadding / (tileSizeWithoutPadding * tileSizeWithoutPadding);
const levelCount = Math.log2(tileSizeWithoutPadding) + 1;

const tileH = Math.ceil(imageWidth / tileSizeWithoutPadding);
const tileV = Math.ceil(imageHeight / tileSizeWithoutPadding);

let levelTileCount = [];
• • •
for (let l = 0; l < levelCount; ++l) {
    const leveltileSizeWithoutPadding = tileSizeWithoutPadding * Math.pow(2, l);

    const levelTileH = Math.ceil(imageWidth / leveltileSizeWithoutPadding);
    const levelTileV = Math.ceil(imageHeight / leveltileSizeWithoutPadding);

    levelTileCount.push(...[levelTileH, levelTileV, leveltileSizeWithoutPadding, 0]);
}

let overallTileCount = 0;

for (let i = 0; i < levelCount; ++i) {
    overallTileCount += levelTileCount[i * 4] * levelTileCount[i * 4 + 1];
}

이 설정에서 `imageWidth`와 `imageHeight`는 원본 이미지 크기를 하드코딩합니다. `textureSizeWithoutPadding`은 보이는 타일을 저장하는 데 사용할 텍스처 맵의 크기를 나타냅니다. `maxVisibleTileCountOnTexture`는 텍스처 맵의 크기에 의해 제한되는 최대 가시 타일 수입니다.

`levelCount`는 전체 레벨 수로, 256 크기의 타일을 단일 픽셀로 축소하는 데 필요한 레벨 수입니다. `tileH`와 `tileV`는 원본 이미지의 수평 및 수직 타일 수입니다.

`levelTileCount`는 모든 레벨에 대한 수평 및 수직 타일 수와 각 레벨의 원본 이미지에 대한 타일 크기를 기록하는 배열입니다. `overallTileCount`는 모든 레벨의 타일 수의 합계입니다.

function keyToLevel(key) {
    let keyRemain = key;
    let level = 0;

    while (keyRemain >= levelTileCount[level * 4] * levelTileCount[level * 4 + 1]) {
        keyRemain -= levelTileCount[level * 4] * levelTileCount[level * 4 + 1];
        level += 1;
    }

    return { level, tileH: levelTileCount[level * 4], tileV: levelTileCount[level * 4 + 1], tileSizeWithoutPadding: levelTileCount[level * 4 + 2], keyRemain }
}

function key(width, height, x, y, level) {
    let base = 0;
    let tileH = 0;
    let tileV = 0;
    for (let i = 0; i < level; ++i) {
        tileH = Math.ceil(width / (tileSizeWithoutPadding * Math.pow(2, level)));
        tileV = Math.ceil(height / (tileSizeWithoutPadding * Math.pow(2, level)));
        base += tileH * tileV;
    }

    return y * tileH + x + base;
}

우리 프로그램에서는 키-값 저장소를 타일 캐시로 사용할 것입니다. 타일의 가로 및 세로 좌표와 레벨을 포함한 타일의 정보를 키로 직렬화하는 함수와 키를 다시 해당 타일 정보로 변환하는 두 가지 함수를 정의해야 합니다.

우리 프로그램에서 사용되는 키는 타일을 고유하게 식별하는 단일 인덱스입니다. 모든 레벨에 걸친 모든 타일을 피라미드로 시각화하면, 레벨 0부터 레벨 8까지 피라미드를 단일 문자열로 늘여서 각 타일에 인덱스를 할당합니다. 주어진 레벨에서 좌표 (x, y) (레벨 좌표라고 함)에 있는 타일의 ID는 `tileH * y + x + 그 아래 레벨의 모든 타일 수`로 계산됩니다.

유사하게, 주어진 키를 사용하여 해당 타일의 x, y, 레벨을 복구할 수 있습니다.

우리가 수행해야 할 첫 번째 작업은 가시성 테스트입니다. 렌더링의 첫 번째 패스에서는 실제로 어떤 타일이 보이는지 결정하는 것을 목표로 합니다. 가시성 테스트 셰이더를 살펴보겠습니다:

@group(0) @binding(0)
var transform: mat4x4;
@group(0) @binding(1)
var projection: mat4x4;

const tileSizeWithoutPadding:f32 = 256.0;

struct VertexOutput {
    @builtin(position) clip_position: vec4,
    @location(0) tex:vec2,
    @location(1) @interpolate(flat) tile:vec2
};

@vertex
fn vs_main(
    @location(0) inPos: vec4,
    @location(1) loc: vec2
) -> VertexOutput {
    var out: VertexOutput;
    out.tile = loc;
    out.tex = inPos.zw ;
    out.clip_position = projection * transform * vec4(inPos.xy + vec2(loc)*tileSizeWithoutPadding, 0.0, 1.0);
    return out;
}

@group(0)
@binding(2)
var level_tile_count: array, 8>; //must align to 16bytes
@group(0)
@binding(3)
var visible_tiles: array;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
    var dx:vec2 = dpdx(in.tex * tileSizeWithoutPadding);
    var dy:vec2 = dpdy(in.tex * tileSizeWithoutPadding);
    var d:f32 = max(dot(dx, dx), dot(dy, dy));
    var level:f32 = min(max(f32(floor(0.5*log2(d))),0.0),8.0);

    var edge_size:f32 = pow(2, level) * tileSizeWithoutPadding;

    var x:f32 = (f32(in.tile.x) * tileSizeWithoutPadding + in.tex.x*tileSizeWithoutPadding) / edge_size;
    var y:f32 = (f32(in.tile.y) * tileSizeWithoutPadding + in.tex.y*tileSizeWithoutPadding) / edge_size;

    var base:i32 = 0;
    if (level > 0) {
        for(var i:i32 = 0;i

정점 셰이더는 비교적 간단합니다. 두 개의 입력이 있습니다: `inPos`는 원점에 위치한 256x256 타일의 정점 중 하나를 나타내며, 타일의 텍스처 좌표입니다. `loc`은 타일의 레벨 좌표, 즉 `tileH`와 `tileV`입니다. 정점 셰이더는 `tileH`와 `tileV`에 따라 타일의 정점에 오프셋을 적용하여 타일을 올바르게 배치합니다.

정점 셰이더는 세 가지 유형의 정보를 프래그먼트 셰이더에 전달합니다: 클립 위치, 텍스처 좌표, 그리고 현재 타일의 레벨 좌표. `@interpolate(flat)` 데코레이션에 주목하세요. 타일 좌표는 정수이므로, 그래픽스 파이프라인이 이에 대한 어떤 보간도 수행하지 않기를 원합니다.

더 많은 설명이 필요한 것은 프래그먼트 셰이더입니다. 이 셰이더는 기본적으로 내장된 `textureSample` 함수의 기능을 복제합니다. 우리는 텍스처 레벨을 명시적으로 얻기 위해 이 함수를 수동으로 구현합니다.

이 셰이더의 고급 개념은 `dpdx` 및 `dpdy` 미분 함수를 포함하는데, 이 함수들은 값 p가 x 및 y 축을 따라 얼마나 빠르게 변하는지를 측정합니다. 예를 들어, `dpdx(in.tex * tileSizeWithoutPadding)`는 두 개의 수평으로 인접한 프래그먼트 사이에서 `in.tex * tileSizeWithoutPadding` 값의 변화 속도를 계산합니다. 프래그먼트 셰이더는 컴퓨트 셰이더처럼 많은 호출을 병렬로 실행하며, 각 호출은 다른 프래그먼트를 처리합니다. 이러한 미분 함수의 직관적이지 않은 측면은 단일 프래그먼트가 자체적으로 변화율을 측정할 수 없다는 것입니다. 이는 다른 프래그먼트 셰이더 실행과의 동기화, 즉 스레드 동기화를 필요로 합니다. 이는 관련 코드가 균일한 제어 흐름 내에 있어야 함을 의미하며, 이는 미분 함수를 사용하는 데 필수적인 요구 사항입니다.

이전에 밉맵 챕터에서 `textureSample` 함수가 왜 균일한 제어 흐름 내에 있어야 하는지 설명하지 않았습니다. 그 이유는 이제 명확합니다: `textureSample`은 텍스처 레벨을 얻기 위해 내부적으로 미분 함수에 의존하기 때문입니다.

`in.tex * tileSizeWithoutPadding`의 변화 속도를 측정하는 것이 텍스처 레벨을 결정하는 데 어떻게 도움이 되는지 아직 설명하지 않았습니다. `in.tex` 값은 0.0에서 1.0까지이고 `tileSizeWithoutPadding`은 256이므로, `tileSizeWithoutPadding`에 텍스처 좌표를 곱한 값은 [0.0, 256.0] 범위를 가집니다.

스크린 공간과 평행한 단일 256x256 타일을 생각해 봅시다. 확대 또는 축소가 없는 경우, 두 개의 수평으로 인접한 프래그먼트에서 `tileSizeWithoutPadding * in.tex` 값의 변화는 1.0보다 작거나 같아야 합니다. 이 시나리오에서는 레벨 0이 최상의 렌더링 품질을 제공하므로 샘플링을 위해 텍스처 레벨 0(`max(log2(d), 0)`)을 선택하고자 합니다. 만약 축소하면, 256x256 타일은 화면에서 더 작게 나타날 것입니다. 이 경우, 인접한 프래그먼트는 1.0보다 큰 값 변화를 가질 것이지만, 여전히 `max(log2(d), 0)`을 사용하여 레벨을 결정할 수 있습니다. 극단적인 경우, 전체 256x256 타일이 단일 픽셀보다 작거나 같은 경우, 미분값은 256보다 커지므로 `log2(256) = 8` 레벨에서 샘플링해야 합니다.

dx와 dy는 다른 속도로 변할 수 있습니다. 셰이더에서는 dx와 dy를 별도로 고려하지 않고 둘 중 최대 변화값을 선택합니다.

레벨을 결정한 후, 다음 단계는 이전에 설명한 대로 키를 얻는 것입니다. 먼저, 원본 이미지에서 현재 프래그먼트의 위치를 계산한 다음, 이 위치를 레벨 타일 크기로 나눕니다. 레벨 좌표는 해당 레벨에서 타일의 인덱스를 제공합니다. 각 레벨의 타일 수를 `level_tile_count`로 전달하여 모든 하위 레벨의 타일 수를 세고 현재 프래그먼트에 대한 고유한 타일 ID를 얻을 수 있습니다.

마지막으로, 가시성 테이블 `var visible_tiles: array;`을 업데이트합니다. 이 스토리지 버퍼는 쓰기 가능하며, 각 타일에 대해 타일 ID로 인덱싱된 항목을 가집니다. 우리가 렌더링하는 모든 프래그먼트에 대해 해당 타일의 항목을 1로 설정하여 "가시성"을 나타냅니다. 다음 단계에서는 모든 가시 타일 정보를 수집하여 실제 렌더링을 위한 단일 텍스처 맵을 조립합니다.

이제 이 셰이더와 함께 작동하는 해당 JavaScript 코드를 살펴보겠습니다:

const positionAttribDesc = {
    shaderLocation: 0, // @location(0)
    offset: 0,
    format: 'float32x4'
};

const positionBufferLayoutDesc = {
    attributes: [positionAttribDesc],
    arrayStride: 4 * 4, // sizeof(float) * 4
    stepMode: 'vertex'
};

const positions = new Float32Array([
    tileSizeWithoutPadding, 0.0, 1.0, 0.0,
    tileSizeWithoutPadding, tileSizeWithoutPadding, 1.0, 1.0,
    0.0, 0.0, 0.0, 0.0,
    0.0, tileSizeWithoutPadding, 0.0, 1.0
]);

this.positionBuffer = createGPUBuffer(device, positions, GPUBufferUsage.VERTEX);

let tiles = [];

for (let y = 0; y < tileV; ++y) {
    for (let x = 0; x < tileH; ++x) {
        tiles.push(x);
        tiles.push(y);
    }
}

const tileLocAttribDesc = {
    shaderLocation: 1, // @location(0)
    offset: 0,
    format: 'uint32x2'
};

const tileLocBufferLayoutDesc = {
    attributes: [tileLocAttribDesc],
    arrayStride: 4 * 2, // sizeof(int) * 3
    stepMode: 'instance'
};

this.tileLocBuffer = createGPUBuffer(device, new Uint32Array(tiles), GPUBufferUsage.VERTEX);

const levelTileCountBuffer = createGPUBuffer(device, new Uint32Array(levelTileCount), GPUBufferUsage.UNIFORM);

this.tileVisibilityBufferZeros = createGPUBuffer(device, new Uint32Array(overallTileCount), GPUBufferUsage.COPY_SRC);
this.tileVisibilityBuffer = createGPUBuffer(device, new Uint32Array(overallTileCount), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST);
this.tileVisibilityBufferRead = createGPUBuffer(device, new Uint32Array(overallTileCount), GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST);
• • •
encodeVisibility(encoder) {
    encoder.setPipeline(this.visibilityPipeline);
    encoder.setBindGroup(0, this.uniformBindGroupVisibility);
    encoder.setVertexBuffer(0, this.positionBuffer);
    encoder.setVertexBuffer(1, this.tileLocBuffer);
    encoder.draw(4, tileH * tileV);
}
• • •
commandEncoder.copyBufferToBuffer(tile.tileVisibilityBufferZeros, 0,
    tile.tileVisibilityBuffer, 0, overallTileCount * 4);
const passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
tile.encodeVisibility(passEncoder);
passEncoder.end();
commandEncoder.copyBufferToBuffer(tile.tileVisibilityBuffer, 0,
    tile.tileVisibilityBufferRead, 0, overallTileCount * 4);

셰이더에서 볼 수 있듯이, 두 개의 정점 속성이 있습니다. `positionBuffer`는 256x256 타일의 네 정점을 원점에 배치하고, `tileLocBufferLayoutDesc`는 레벨 좌표 x와 y의 집합을 가집니다. 이 속성은 인스턴스별 속성입니다. 우리는 인스턴싱 기술을 사용하여 각 레벨 좌표 집합에 대해 256x256 타일을 복제할 것입니다.

`tileVisibilityBuffer`는 출력 배열을 포함합니다. 각 렌더링 라운드 전에 이 버퍼를 지워야 합니다. `tileVisibilityBuffer`를 지우기 위해 동일한 크기로 0으로 채워진 `tileVisibilityBufferZeros`를 사용합니다. `tileVisibilityBufferRead`는 결과 읽기용으로 사용됩니다.

명령 인코딩의 경우, `tileH * tileV` 인스턴스에 대해 타일을 그릴 것입니다. 이는 전체 이미지를 커버할 것입니다.

await tile.tileVisibilityBufferRead.mapAsync(GPUMapMode.READ, 0, overallTileCount * 4);

let vb = tile.tileVisibilityBufferRead.getMappedRange(0, overallTileCount * 4);

vb = new Uint32Array(vb);
let vt = [];
for (let i = 0; i < overallTileCount; ++i) {
    if (vb[i] == 1) {
        vt.push(i);
    }
}
tile.tileVisibilityBufferRead.unmap();

await visibleTiles.assembleTexture(device, imageWidth,
    imageHeight, vt);

버퍼 제출 후, 우리는 결과 버퍼를 다시 읽고, 보이는 타일의 인덱스를 추출하여 해시 테이블 `visibleTiles`에 전달하여 실제 텍스처 맵을 조립합니다. 이제 텍스처 맵이 어떻게 조립되는지 살펴보겠습니다:

class KeyIdManager {

    constructor() {
        this.used = new Map();
        this.available = [];

        for (let i = 0; i < maxVisibleTileCountOnTexture; ++i) {
            this.available.push(i);
        }
    }

    async generate(keys, loadTileIntoTexture) {
        let newUsed = new Map();
        let result = [];

        //1. gather tiles that should be visible for the next round
        let keySet = new Set();
        for (let k of keys) {
            keySet.add(k);
        }

        //2. eliminate tiles that are not visible this round but was visible in the previous round, by
        // adding them into the available array
        for (const [uk, uv] of this.used) {
            if (!keySet.has(uk)) {
                this.available.push(uv);
            }
        }

        //3. for all visible tiles
        console.log("number of keys", keys);
        for (let k of keys) {

            const l = keyToLevel(k);
            console.log("debug level", l)

            // if this tile was visible before, skip updating texture map
            if (this.used.has(k)) {
                const id = this.used.get(k);
                newUsed.set(k, id);
                result.push({ key: k, id });
            }
            else {
                const id = this.available.shift();
                newUsed.set(k, id);
                result.push({ key: k, id });
                const level = l.level;
                const x = l.keyRemain % l.tileH;
                const y = Math.floor(l.keyRemain / l.tileH);
                console.log("debug load tex", x, y, level);
                loadTileIntoTexture(x, y, level, k, id);
            }
        }
        this.used = newUsed;
        return result;
    }
}

먼저, `KeyIdManager`라는 헬퍼 클래스를 살펴보겠습니다. 실제 텍스처 맵의 크기는 2048x2048이고 타일 크기는 256x256입니다. 따라서 실제 텍스처 맵은 최대 64개의 타일을 담을 수 있습니다. 우리는 `maxVisibleTileCountOnTexture` 개수의 가시 타일이 어떤 64개 지점을 차지했는지 관리하는 클래스가 필요합니다. 새로운 가시성 테스트 라운드 후에, 이 매니저는 보이지 않는 타일을 재활용하고 보이는 타일을 텍스처 맵에 로드할 것입니다. 더 고급 버전에서는 LRU 캐시를 구현하여 가장 적게 사용된 타일을 먼저 제거할 수 있습니다. 여기서는 단순화를 위해 보이지 않는 타일은 항상 제거합니다.

클래스에서 `available`은 사용 가능한 스팟 ID 목록입니다. 처음에는 64개의 모든 스팟이 사용 가능하므로, 모든 ID를 목록에 추가합니다. `used` 변수는 타일의 키에서 텍스처 맵의 ID로 매핑하는 맵입니다.

핵심 함수는 `generate`입니다. 입력은 모든 보이는 타일의 키와 타일을 텍스처 맵에 붙여넣을 수 있는 헬퍼 함수입니다.

이 함수의 로직은 간단합니다. 먼저, 모든 키를 해시 테이블(세트)에 로드합니다. 많은 존재 여부 질의를 수행해야 하기 때문입니다. 다음으로, 이전 라운드에서 보였던 모든 타일을 방문합니다. 이 라운드에서 보이지 않게 된 타일이 있다면, 해당 ID를 `available` 목록으로 재활용합니다. 그런 다음, 이 라운드의 모든 보이는 타일을 방문합니다. 이 타일이 이전 라운드에서도 보였다면, 로드 단계를 건너뜁니다. 그렇지 않으면, 텍스처 로딩 유틸리티 함수를 사용하여 타일을 텍스처의 사용 가능한 스팟에 붙여넣습니다.

이제 로딩 유틸리티 함수의 구현을 살펴보겠습니다:

async loadTileIntoTexture(device, bufferUpdate, imageWidth, imageHeight, x, y, level, tileKey, id) {
    const writeArray = new Float32Array(bufferUpdate.getMappedRange(tileKey * 2 * 4, 8));
    writeArray.set([(tileSizeWithoutPadding / textureSizeWithoutPadding) * (id % (textureSizeWithoutPadding / tileSizeWithoutPadding)),
    (tileSizeWithoutPadding / textureSizeWithoutPadding) * Math.floor(id / (textureSizeWithoutPadding / tileSizeWithoutPadding))]);

    const url = '../crab_nebula/crab_' + level + '_' + y + '_' + x + '.png';

    const response = await fetch(url);
    const blob = await response.blob();
    const imgBitmap = await createImageBitmap(blob);
    //console.log(url,imgBitmap.width, imgBitmap.height,{ width: tileSizeWithoutPadding+padding*2, height: tileSizeWithoutPadding+padding*2 });

    device.queue.copyExternalImageToTexture({ source: imgBitmap }, {
        texture: this.texture,
        origin: { x: (padding * 2 + tileSizeWithoutPadding) * (id % (textureSizeWithoutPadding / tileSizeWithoutPadding)), y: (padding * 2 + tileSizeWithoutPadding) * Math.floor(id / (textureSizeWithoutPadding / tileSizeWithoutPadding)) }
    }, { width: tileSizeWithoutPadding + padding * 2, height: tileSizeWithoutPadding + padding * 2 });
}

이 함수는 두 가지를 수행합니다. 첫째, 텍스처 룩업 테이블을 업데이트합니다. 룩업 테이블의 각 타일에는 두 개의 부동 소수점 숫자로 구성된 해당 항목이 있습니다. 이 숫자들은 텍스처에서 타일의 왼쪽 상단 모서리 텍스처 좌표입니다.

다음으로, `fetch` API를 사용하여 해당 타일 이미지를 `imageBitmap`으로 로드합니다. 이제 파일 이름이 유용하게 사용되어 올바른 타일 이미지를 가져오는 데 활용됩니다.

마지막으로, `copyExternalImageToTexture` 함수를 사용하여 `imageBitmap`을 텍스처 맵에 붙여넣습니다. 실제 타일에는 패딩이 있고 텍스처 맵에도 패딩이 있으므로, 텍스처 맵에서 타일의 위치를 계산할 때 패딩을 고려해야 합니다.

다음으로, `VisibleTileHashTable` 클래스를 살펴보겠습니다. 이 클래스는 텍스처 업데이트와 관련된 모든 것을 캡슐화합니다.

class VisibleTileHashTable {
    constructor() {
        this.texture = null;
        this.tileTexCoordBuffer = null;
        this.tileTexCoordBufferUpdate = null;
        this.keyIdManager = new KeyIdManager();
    }

    setup(device) {
        const textureDesc = {
            size: [textureSizeWithoutPadding + textureSizeWithoutPadding / tileSizeWithoutPadding * padding * 2, textureSizeWithoutPadding + textureSizeWithoutPadding / tileSizeWithoutPadding * padding * 2, 1],
            dimension: '2d',
            format: 'rgba8unorm',
            usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC
        };
        this.texture = device.createTexture(textureDesc);
        this.tileTexCoordBuffer = createGPUBuffer(device, new Float32Array(overallTileCount * 2), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
        this.tileTexCoordBufferUpdate = createGPUBuffer(device, new Float32Array(overallTileCount * 2), GPUBufferUsage.COPY_SRC | GPUBufferUsage.MAP_WRITE);
    }
• • •
    async assembleTexture(device, imageWidth, imageHeight, tiles) {
        await this.tileTexCoordBufferUpdate.mapAsync(GPUMapMode.WRITE, 0, overallTileCount * 2 * 4);

        tiles = await this.keyIdManager.generate(tiles, async (x, y, level, k, id) => {
            await this.loadTileIntoTexture(device, this.tileTexCoordBufferUpdate, imageWidth, imageHeight, x, y, level, k, id);
        });

        console.log(tiles);

        this.tileTexCoordBufferUpdate.unmap();
    }
}

이 클래스는 네 가지 멤버를 포함합니다: 텍스처, 룩업 테이블, 룩업 테이블 업데이트를 위한 추가 버퍼, 그리고 `keyIdManager`입니다.

새로운 가시성 테스트 라운드를 수행한 후에는 `assembleTexture` 함수를 호출하여 새 텍스처를 빌드해야 합니다. 이 함수는 룩업 테이블의 업데이트 버퍼를 매핑하고 이전에 설명한 다른 헬퍼 함수에 전달합니다.

마지막으로, 가시성 테스트가 완료되면 타일을 렌더링하기 위한 두 번째 패스를 실행합니다. 이는 다음 셰이더로 수행됩니다:

@group(0)
@binding(2)
var level_tile_count: array, 8>; //must align to 16bytes
@group(0)
@binding(3)
var hash: array >;
@group(0) @binding(4)
var t_diffuse: texture_2d;
@group(0) @binding(5)
var s_diffuse: sampler;

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
    var dx:vec2 = dpdxFine(in.tex * tileSizeWithoutPadding);
    var dy:vec2 = dpdyFine(in.tex * tileSizeWithoutPadding);
    var d:f32 = max(dot(dx, dx), dot(dy, dy));
    var level:f32 = min(max(f32(floor(0.5*log2(d))),0.0), 8.0);
    var edge_size:f32 =  pow(2, level) * tileSizeWithoutPadding;

    var x:f32 = (f32(in.tile.x) * tileSizeWithoutPadding + in.tex.x * tileSizeWithoutPadding) / edge_size;
    var y:f32 = (f32(in.tile.y) * tileSizeWithoutPadding + in.tex.y * tileSizeWithoutPadding) / edge_size;

    var base:i32 = 0;
    if (level > 0) {
        for(var i:i32 = 0;i

이 셰이더는 가시성 테스트 셰이더와 매우 유사합니다. 사실, 정점 셰이더는 완전히 동일하므로 여기서는 생략하겠습니다. 프래그먼트 셰이더도 크게 다르지 않습니다. 이제 해시 테이블은 읽기 전용입니다. 우리는 여전히 각 보이는 타일의 키를 얻기 위해 동일한 계산을 수행합니다.

계산이 복잡해 보일 수 있으므로 분석해 봅시다. 먼저, 패딩이 없다고 가정합니다. `x-floor(x)`는 현재 프래그먼트에 대한 현재 보이는 타일의 텍스처 좌표를 나타냅니다. 현재 보이는 타일은 텍스처의 일부일 뿐이므로, 이 로컬 텍스처 좌표를 텍스처 맵의 전역 텍스처 좌표로 변환해야 합니다.

우리의 룩업 테이블에는 타일 키와 텍스처 맵에서 타일의 왼쪽 상단 모서리 텍스처 좌표 간의 대응 관계가 포함되어 있음을 상기하세요. 따라서 `hash[base]`는 왼쪽 상단 모서리의 전역 텍스처 좌표를 제공합니다. 전역 텍스처 좌표는 `hash[base] + local_coordinates * tile_size / texture_size`로 얻을 수 있습니다.

이것은 패딩 없이 작동합니다. 패딩이 있는 경우, 로컬 좌표를 약간 조정해야 합니다. 먼저, 로컬 좌표에 `tileSizeWithoutPadding`을 곱하여 픽셀 단위의 로컬 좌표를 얻습니다. 그런 다음 패딩을 추가하고 이 조정된 좌표를 좌표가 있는 타일 크기로 나누어 새로운 로컬 좌표를 얻습니다. 나머지 계산은 동일합니다.

이 과정을 통해 각 프래그먼트에 대한 정확한 텍스처 좌표를 얻을 수 있으며, 이를 사용하여 텍스처 맵에서 색상 값을 찾아냅니다.

두 번째 렌더링 패스를 설정하는 JavaScript 코드는 첫 번째 패스와 공유되므로 여기서는 세부 사항을 생략하겠습니다. 그러나 탐색 코드를 살펴보는 것은 가치가 있습니다.

let translateMatrix = glMatrix.mat4.lookAt(glMatrix.mat4.create(),
    glMatrix.vec3.fromValues(0, 0, 10), glMatrix.vec3.fromValues(0, 0, 0), glMatrix.vec3.fromValues(0.0, 1.0, 0.0));

let orthProjMatrix = glMatrix.mat4.ortho(glMatrix.mat4.create(), canvas.width * -0.5 * scale, canvas.width * 0.5 * scale, canvas.height * 0.5 * scale, canvas.height * -0.5 * scale, -1000.0, 1000.0);

let translateMatrixUniformBuffer = createGPUBuffer(device, translateMatrix, GPUBufferUsage.UNIFORM);

let projectionMatrixUniformBuffer = createGPUBuffer(device, orthProjMatrix, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
• • •
    canvas.onmousedown = (e) => {
        isDragging = true;
        var rect = canvas.getBoundingClientRect();
        prevX = event.clientX - rect.left;
        prevY = event.clientY - rect.top;
        e.preventDefault();
        e.stopPropagation();
    }

    canvas.onmousemove = (e) => {
        if (isDragging) {
            var rect = canvas.getBoundingClientRect();
            x = event.clientX - rect.left;
            y = event.clientY - rect.top;

            let deltaX = prevX - x;
            let deltaY = prevY - y;

            pivotX += deltaX * scale;
            pivotY += deltaY * scale;

            updatedProjectionMatrix = glMatrix.mat4.ortho(glMatrix.mat4.create(), pivotX - canvas.width * 0.5 * scale, pivotX + canvas.width * 0.5 * scale, pivotY + canvas.height * 0.5 * scale, pivotY - canvas.height * 0.5 * scale, -1000.0, 1000.0);

            prevX = x;
            prevY = y;
            //requestAnimationFrame(render);
        }
        e.preventDefault();
        e.stopPropagation();
    }

    canvas.onmouseup = (e) => {
        isDragging = false;
        e.preventDefault();
        e.stopPropagation();
    }

    canvas.onwheel = (e) => {
        scale += e.deltaY * 0.01
        if (scale < 0.01) {
            scale = 0.01;
        }
        else if (scale > 100.0) {
            scale = 100.0;
        }

        console.log(scale);
        e.preventDefault();
        e.stopPropagation();

        updatedProjectionMatrix = glMatrix.mat4.ortho(glMatrix.mat4.create(), pivotX - canvas.width * 0.5 * scale, pivotX + canvas.width * 0.5 * scale, pivotY + canvas.height * 0.5 * scale, pivotY - canvas.height * 0.5 * scale, -1000.0, 1000.0);
    }
}

패닝은 변환 행렬을 업데이트하여 이루어집니다. `lookAt` 함수를 사용하여 업데이트된 행렬을 도출합니다. 처음에는 원점을 바라보며, 마우스 움직임은 `lookAt`의 `from` 및 `to` 매개변수를 모두 업데이트합니다.

줌은 투영 행렬을 업데이트하여 이루어집니다. 이미지 보기에는 직교 행렬만 사용합니다. 확대/축소 시에는 직교 행렬의 보기 범위를 조정합니다.

마지막으로, 텍스처에 패딩을 포함하지 않으면 어떻게 되는지 논의해 봅시다. 효과를 보기 위해 패딩을 0으로 설정할 수 있습니다. 보시다시피, 타일 사이에 이음새가 보입니다. 이러한 이음새는 텍스처 맵을 샘플링할 때 발생하는 숫자 오류로 인해 발생합니다. 타일 경계 근처의 좌표를 샘플링하면 인근 타일에서 읽을 수 있습니다. 이를 피하기 위해 타일 사이에 추가 버퍼를 추가하여 타일 경계 근처를 샘플링할 때 인접 타일 대신 버퍼 영역에서 읽도록 합니다.

GitHub에 의견 남기기