Tessellation & Terrains
카테고리: Metal
Main Reference
- Metal by Tutorials - 4th edition
To achieve a fine details without using normal maps requires a change of model geometry by adding more vertices. The problem with adding more vertices is that when you send them to the GPU, it chokes up the pipeline. A hardware tessellator in the GPU can create vertices on the fly, adding a greater level of detail and thereby using fewer resources.
Tessellation

For tessellation, instead of sending vertices to the GPU, you send patches. These triangle or quad patches are made up of control points. The tessellator can convert each quad patch into a certain number of trianlges(up to 4096 in iMac, 256 in iPhone). Because the GPU doesn’t store tessellated vertices in graphics memory, it’s more efficient on resources.


(GPU/CPU side 작업들인데 무슨 내용인지 아직 모르겠음)
Tessellation Patches
| Patches | Spline curve |
|---|---|
![]() |
![]() |
- Patches
- bilinear : 4 control points
- biquadratic : 9 control points
- bicubic : 16 control points.
Control Point들을 이용해 cage(뼈대)를 만들고, 이를 이용해 부드러운 spline curve를 생성한다. Control points를 interpolate 해 곡선을 만드는 알고리듬은 다양하지만, 예제에서는 각 선분의 중점을 이용한 bezier curve 알고리듬을 이용.
Tessellation Factors

4개의 control point들이 GPU로 보내지면, hardware tessellator는 이를 기반으로 수 많은 vertices들을 생성한다. 이때는 단순히 동일한 평면 위의 점들이 생성되지만 추후 vertex function에서 생성된 점들을 이동시킨다.
For each patch, you nee to specify outside edge factors and inside edge factors.
- [2, 4, 8, 16] : outside - different edge factors for each edge
- [8, 16] : inside - for horizontal and vertical respectively (그림 오타인듯)
var edgeFactors: [Float] = [4] // 패치의 바깥 경계
var insideFactors: [Float] = [4] // 패치의 내부 경계
참고로 Tessellation Factor는 Float 타입으로 정의한다. 이때 GPU의 Tessellator는 해당 값을 내부적으로 ceiling 처리해 정수로 변환해 사용한다. 그럼에도 Float 타입으로 받는 이유는 LOD를 유연하고 부드럽게 표현하기 위해서이다.
lazy var tessellationFactorsBuffer: MTLBuffer? = {
let count = patchCount * (4 + 2)
let size = count * MemoryLayout<Float>.size / 2
return Renderer.device.makeBuffer(
length: size,
options: .storageModePrivate)
}()
- Patch 하나 당 (4 + 2) 개의 factor (outside + inside)
- half-float 타입으로 바인딩
Setting Up the Patch Data
static func createControlPoints(
patches: (horizontal: Int, vertical: Int),
size: (width: Float, height: Float)
) -> [float3] {
var points: [float3] = []
let width = 1 / Float(patches.horizontal)
let height = 1 / Float(patches.vertical)
for row in 0..<patches.vertical {
let row = Float(row)
for index in 0..<patches.horizontal {
let column = Float(index)
let left = width * column
let bottom = height * row
let right = width * column + width
let top = height * row + height
points.append([left, 0, top])
points.append([right, 0, top])
points.append([right, 0, bottom])
points.append([left, 0, bottom])
}
}
points = points.map {
[
$0.x * size.width - size.width / 2,
0,
$0.z * size.height - size.height / 2
]
}
return points
}
// Set up RenderPSO
vertexDescriptor.layouts[0].stepFunction = .perPatchControlPoint
- Quad 데이터 하나를 GPU로 보내려면 6개의 vertex data가 필요
- 어차피 tessellation 단계에서 새로운 vertices들이 추가되므로 quad 하나 당 vertex 4개씩만
- vertexDescriptor에서 setpFunction 옵션을
.perPatchControlPoint로 세팅- patch마다 데이터 가져오도록 지정
Tessellation Kernel Function
kernel void tessellation_main(
constant float *edge_factors [[buffer(0)]],
constant float *inside_factors [[buffer(1)]],
device MTLQuadTessellationFactorsHalf *factors [[buffer(2)]],
uint pid [[thread_position_in_grid]])
{
factors[pid].edgeTessellationFactor[0] = edge_factors[0];
factors[pid].edgeTessellationFactor[1] = edge_factors[0];
factors[pid].edgeTessellationFactor[2] = edge_factors[0];
factors[pid].edgeTessellationFactor[3] = edge_factors[0];
factors[pid].insideTessellationFactor[0] = inside_factors[0];
factors[pid].insideTessellationFactor[1] = inside_factors[0];
}
- Compute Pass를 통해 각 패치에 대한 factor값 채워주기
- 추후에는 카메라와의 distance를 계산해 패치마다 factor가 바뀔 수 있음
Render Pass
renderEncoder.setTessellationFactorBuffer(
tessellationFactorsBuffer,
offset: 0,
instanceStride: 0)
// ...
renderEncoder.drawPatches(
numberOfPatchControlPoints: 4,
patchStart: 0,
patchCount: patchCount,
patchIndexBuffer: nil,
patchIndexBufferOffset: 0,
instanceCount: 1,
baseInstance: 0)
// shaders.metal
struct ControlPoint {
float4 position [[attribute(0)]];
};
[[patch(quad, 4)]]
vertex VertexOut
vertex_main(patch_control_point<ControlPoint> control_points [[stage_in]],
constant Uniforms &uniforms [[buffer(BufferIndexUniforms)]],
float2 patch_coord [[position_in_patch]])
{
float u = patch_coord.x;
float v = patch_coord.y;
float2 top = mix(control_points[0].position.xz,
control_points[1].position.xz,
u);
float2 bottom = mix(control_points[3].position.xz,
control_points[2].position.xz,
u);
VertexOut out;
float2 interpolated = mix(top, bottom, v);
float4 position = float4(interpolated.x, 0.0,
interpolated.y, 1.0);
out.position = uniforms.mvp * position;
out.color = float4(u, v, 0, 1);
return out;
}
Post Tessellation Vertex Function
ControlPoint: 구조체는 vertexDescriptor에서 설정한 세팅과 동일하게[[patch(quad, 4)]]: tells the vertices are coming from tessellated patches.patch_control_point<ControlPoint>: Per-patch control point data.[[position_in_patch]]: Tessellator provides a uv coordinate between 0 and 1 for the tessellated patch

Multiple Patches
let patches = (horizontal: 2, vertical: 2)
// shaders.metal
vertex_main(...,
uint patchID [[patch_id]])
{
if (patchID == 0) {
out.color = float4(1, 0, 0, 1);
}
}

Post tessellation vertex function의 경우 파라미터로 [[patch_id]] attribute를 받아올 수 있음(어따쓰지..?). -> patch_id를 통해 다른 버퍼에서 해당하는 데이터 꺼내올 때 필요
Tessellation By Distance (Terrain)
float calc_distance(
float3 pointA, float3 pointB,
float3 camera_position,
float4x4 modelMatrix)
{
float3 positionA = (modelMatrix * float4(pointA, 1)).xyz;
float3 positionB = (modelMatrix * float4(pointB, 1)).xyz;
float3 midpoint = (positionA + positionB) * 0.5;
float camera_distance = distance(camera_position, midpoint);
return camera_distance;
}
- world space에서 두 control points의 mid point를 구하고, 카메라와의 거리 계산
- 패치의 중점이 아닌 control point의 중점을 이용해야 이웃한 패치와 동일한 tessellation factor를 가짐
- 그렇지 않으면 crack 발생
kernel void tessellation_main(
constant float *edge_factors [[buffer(0)]],
constant float *inside_factors [[buffer(1)]],
device MTLQuadTessellationFactorsHalf *factors [[buffer(2)]],
constant float4 &camera_position [[buffer(3)]],
constant float4x4 &modelMatrix [[buffer(4)]],
constant float3* control_points [[buffer(5)]],
constant Terrain &terrain [[buffer(6)]],
uint pid [[thread_position_in_grid]])
{
uint index = pid * 4;
float totalTessellation = 0;
for (int i = 0; i < 4; i++) {
int pointAIndex = i;
int pointBIndex = i + 1;
if (pointAIndex == 3) {
pointBIndex = 0;
}
int edgeIndex = pointBIndex;
float cameraDistance =
calc_distance(
control_points[pointAIndex + index],
control_points[pointBIndex + index],
camera_position.xyz,
modelMatrix);
float tessellation = max(4.0, terrain.maxTessellation / cameraDistance);
factors[pid].edgeTessellationFactor[edgeIndex] = tessellation;
totalTessellation += tessellation;
}
factors[pid].insideTessellationFactor[0] = totalTessellation * 0.25;
factors[pid].insideTessellationFactor[1] = totalTessellation * 0.25;
}
pointAIndex,pointBIndex: (0,1), (1,2), (2,3), (3,0)- 각 edge마다 tessellation factor는 distance와 반비례
- 내부 factor는 외부 factor의 평균값으로 (이게 표준인듯..?)

Rendering Terrain
vertex VertexOut vertex_main(..)
{
// ...
VertexOut out;
float2 interpolated = mix(top, bottom, v);
float4 position = float4(interpolated.x, 0.0,
interpolated.y, 1.0);
float2 xy = (position.xz + terrain.size / 2.0) / terrain.size;
constexpr sampler sample;
float4 color = heightMap.sample(sample, xy);
out.color = float4(color.r);
float height = (color.r * 2 - 1) * terrain.height;
position.y = height;
out.position = uniforms.mvp * position;
return out;
}
- Texture sampler를 사용하기 위해 patch의 control point(position) 값의 범위를 0~1로 변환
- Heightmap Texture의 pixel format은 R8Unorm이므로 r값만 가져오기
- 해수면(height=0)을 기준으로 - height < 0 : 계곡, 호수, 바다, .. - height > 0 : 산 -

이때 카메라 시점(rotate, zoom)을 바꿀 때 마다 mountain이 잔 물결처럼 바뀌는 현상이 나타나는데, 이는 tessellation의 LOD가 over-sensitive 하기 때문이다. 이런 경우 render pass의 pipeline descriptor에서 tessellation의 partition mode 변경을 통해 문제되는 현상을 완화할 수 있다.
// pipelineDescriptor.tessellationPartitionMode = .fractionalEven
pipelineDescriptor.tessellationPartitionMode = .pow2
tessellationPartitionMode 는 tessellator가 quad 패치를 삼각형으로 쪼갤 때 사용하는 내부 분할 알고리듬을 지정하는 옵션이다.
pow2: 2의 거듭제곱 분할에 최적화된 패턴을 생성.integer: 테셀레이션 계수가 정수일 때 적합한 패턴을 생성.fractionalOdd / fractionalEven: 소수점 이하의 계수(fractional factors)를 사용할 때 LOD(Level of Detail) 변경에 따른 ‘툭’ 끊기는 시각적 결함(popping)을 최소화하도록 설계된 고급 방식
| Simple Shading | Using Slope |
|---|---|
![]() |
![]() |
Metal Performance Shaders(MPS) 라이브러리를 이용하면 sobel filter를 통해 height map의 slope map을 간편하게 구할 수 있다. 이를 이용해 단순 height 값이 아닌 가파르지 않은 영역에만 snow texture를 입힐 수 있는데, 이러면 자연스러운 지형을 만들 수 있다.




댓글 남기기