커밋 26de7be8의 AUTHORING.md를 한국어로 옮긴 것입니다. 패키지는 0.2.0에서 쓸 typeshade라는 이름으로 가져옵니다.
fp64
이 절을 다 읽고 나면 셰이더 안에서 배정밀도 값을 선언하고, 그 값이 어떤 연산을 지원하는지 알고, 에뮬레이션에 필요한 가드 텍스처를 바인딩할 수 있습니다.
GPU에는 64비트 부동소수점 타입이 없습니다. 이 패키지는 f64를 에뮬레이션합니다. 값 하나를
f32 두 개, 곧 상위(hi)와 하위(lo) 쌍으로 나타내고, 실제 값은 이 둘의 합이지만 두 수를
합치지 않고 나란히 든 채로 다룹니다. 그 결과 지수 범위는 f32 그대로이고, 유효 비트만 약
48비트로 늘어납니다. 유효 비트는 값이 몇 자리까지 유지하는지를 정하는 소수부 비트입니다.
월드 좌표처럼 1e8 근처의 값에서는 f32 한 스텝이 이미 8 단위인데, f64를 쓰면 한 단위보다 훨씬
작은 차이까지 구분합니다. 작성 인터페이스는 f32와 같고, 선언하는 타입만 다릅니다. 생성 직전에
실행되는 fp64Lower 패스가 모든 f64를 vec2<f32>로 바꾸고, 셰이더가 호출할 에뮬레이션 함수를
끼워 넣습니다. WGSL과 GLSL, 그리고 기준값을 내는 CPU 오라클의 계산 결과는 모두 일치합니다.
48비트라는 수치는 GPU가 f32의 +, -, *를 가장 가까운 값으로, 동률이면 짝수 쪽으로
반올림한다고 가정합니다. 현재 출시된 GPU는 모두 그렇게 동작하지만, 두 사양 어느 쪽도 이를
보장하지는 않습니다. GLSL ES 3.00은 반올림 모드를 정하지 않고 비정규 수를 영으로 내려도
된다고 허용하며, WGSL도 반올림 모드를 고정하지 않습니다. 이 수치는 그런 하드웨어 관행에
기댄 것이며, 자세한 내용은 docs/use-typeshade-surface.md의 §39에 있습니다.
f64 값 선언하기
유니폼 필드든, fn의 매개변수든, 지역 변수든, f32T를 쓰던 자리에 f64T를 쓰면 됩니다.
산술 메서드, 비교 연산, 내장 함수의 이름은 f32와 같으므로, f32로 쓴 본문을 f64로 바꾸면
대개 시그니처만 달라집니다.
import { f32T, f64T, fn, module, sqrt, toF32, uniformStruct } from 'typeshade'
const U = uniformStruct( 'U', { group: 0, binding: 0, as: 'u' }, { origin: f64T, // one vec2<f32> slot: the host packs splitF64(value) },)
const k = fn('k', { x: f64T, s: f32T }, (p) => toF32(sqrt(p.x.add(U.field.origin).mul(p.s))))const m = module({ funcs: [k], uses: [U] })모듈에 fp64를 쓴다고 따로 표시할 것은 없습니다. 하향 변환(lowering) 패스가 f64 타입을 스스로 찾아냅니다.
변환
산술 연산 안에서 f32는 f64로 암묵적으로 확장되며, 이 확장은 값을 조금도 잃지 않습니다.
그래서 f64인 x와 f32인 dx가 있을 때 x.add(dx)는 따로 변환할 필요가 없고,
x.add(toF64(dx))와 같은 코드를 생성합니다. toF64(x)와 x.f64()는 이 확장을
명시적으로 적는 두 가지 표기이며, 어떤 값이 산술을 만나기 전에 이미 f64여야 하는 경우에
씁니다. JavaScript의 숫자는 원래 배정밀도이므로, f64(1e-9)든 f64 산술 안에 그냥 쓴
1e-9든 빌드 시점에 리터럴을 손실
없이 hi/lo 쌍으로 나눕니다. f64FromParts(hi, lo)는 버텍스 속성 쌍처럼 이미 나뉘어 들어온
f32 두 개로 f64 값 하나를 만듭니다. 좁히는 변환은 항상 명시적으로 해야 합니다. toF32(x)는
hi와 lo를 다시 더해 f32 하나를 돌려주며, 이때 f32에 담기지 않는 정밀도는 사라집니다. f64를
정수나 불리언과 섞어 쓰면 작성 시점에 SD0004 오류가 납니다.
import { f32T, f64, f64FromParts, fn, toF32, toF64 } from 'typeshade'
const g = fn('g', { a: f32T, hi: f32T, lo: f32T }, (p) => { const widened = toF64(p.a) // exact const lit = f64(1e-9) // split at build time const rebuilt = f64FromParts(p.hi, p.lo) // two lanes that were split by the host return toF32(widened.add(lit).add(rebuilt)) // the explicit narrow})f64가 지원하는 연산
사칙연산과 모든 비교 연산, neg, abs, min, max, sqrt, 보간 계수가 f32인 mix,
floor, fract, sin, cos는 f64 피연산자를 받습니다. 이 목록에 없는 연산을 f64
피연산자에 쓰면 생성 시점에 SD0041 오류로 실패하므로, 먼저 toF32로 값을 좁힌 뒤 그
부분의 계산을 f32로 마무리해야 합니다. %와 비트 연산자는 그보다 앞선 작성 시점에 이미
거부됩니다. sin과 cos은 주변의 다른 산술 연산보다 정확도가 낮습니다. 초월함수 자체의
상대 오차는 아무리 좋아도 약 2^-36이 한계이며, 인수가 커질수록 인수 축소(range reduction)
과정에서 오차도 함께 커집니다.
import { f64, f64T, floor, fn, min, toF32 } from 'typeshade'
// A triangle wave on a coordinate that has outgrown f32.const stripe = fn('stripe', { x: f64T }, (p) => { const y = p.x.mul(0.5) const f = y.sub(floor(y)) return toF32(min(f, f64(1).sub(f)))})가드 텍스처
셰이더 컴파일러는 부동소수점 산술의 결합 순서를 바꾸기도 하는데, 그렇게 재결합하면 이
에뮬레이션이 기대는 작은 보정 항이 정확히 지워집니다. 그래서 생성된 헬퍼 함수마다 컴파일러가
미리 알 수 없는 값, 곧 텍스처에서 읽은 1을 보정 항 사이에 끼워 넣습니다. f64 산술이 이
헬퍼 가운데 하나라도 호출하는 모듈에는 _fp64라는 texture_2d<f32> 바인딩이 그룹 0의 첫
번째 빈 바인딩 자리에 자동으로 들어가며, reflect()에서는 보통의 2D 텍스처로 보입니다.
f64 값을 비교하거나, 넓히거나 좁히거나, 부호를 바꾸거나, 두 배, 절반 같은 이진 거듭제곱 배율로 스케일하기만 하는
모듈은 헬퍼를 하나도 호출하지 않으므로 바인딩을 받지 않습니다. 그러니 호스트는 모든
파이프라인에 _fp64를 바인딩하지 말고 reflect()를 읽어 판단합니다. 호스트는 텍셀 값이 정확히 1.0인
1×1 텍스처를 바인딩해야 합니다. 흰색 RGBA8이든 1.0을 담은 R32F든 상관없습니다. 이 값을
텍스처에 두는 이유가 있습니다. 일부 드라이버는 실제로 들어온 유니폼 값에 맞춰 파이프라인을
특수화하고 다시 최적화하는데, 그러면 보정 항이 또 사라집니다. 텍셀 값을 상수로 취급하는
컴파일러는 없습니다.
f64 산술을 하는 함수는 저마다 본문 맨 앞에서 텍셀을 한 번 읽고, 그 값을 헬퍼에 매개변수로
넘깁니다. 그래서 루프는 반복할 때마다 텍셀을 읽지 않고 시작하기 전에 한 번만 읽습니다.
GLSL에서는 이 텍스처를 uniform highp sampler2D _fp64;로 선언합니다. GLSL ES 3.00은 두
스테이지 모두에서 sampler2D의 기본 정밀도를 lowp로 정하고, 텍셀 읽기는 그 샘플러의
정밀도로 값을 돌려주기 때문입니다.
호스트 쪽에서 해야 할 일은 두 가지입니다. 바인드 그룹 레이아웃이 고정되어 있다면 모듈의
uses에 fp64Guard({ group, binding })를 넣어 슬롯을 고정합니다. Apple GPU에서는
가드만으로 충분하지 않습니다. 플랫폼의 기본 fast math가 어차피 부동소수점 기반 하향 변환을
무너뜨리기 때문입니다. 손에 있는 디바이스 신호를 recommendFp64Flavor에 넘기고 그 결과를
생성 옵션 fp64Flavor로 건네면, 이런 기기에서는 정수 기반 하향 변환을 고릅니다. 정수 기반
하향 변환에는 가드 바인딩이 아예 필요 없습니다.
// `k` and `U` are the function and the uniform struct from the first example.import { emitModule, fp64Guard, module, recommendFp64Flavor } from 'typeshade'
const pinned = module({ funcs: [k], uses: [U, fp64Guard({ group: 0, binding: 3 })] })
const flavor = recommendFp64Flavor({ userAgent: navigator.userAgent })const wgsl = emitModule(pinned, { fp64Flavor: flavor })생성된 셰이더에서는 에뮬레이션이 이름 공간 두 개를 차지합니다. df64_로 시작하는 함수
이름이나 DF64Vec, DF64Mat으로 시작하는 구조체 이름을 직접 쓰면 생성 시점에 SD0043
오류가 납니다.
패킹과 레이아웃
f64 유니폼 필드나 버텍스 속성은 보통의 vec2<f32> 슬롯 하나를 차지하며, 크기는 8, 정렬은
8입니다. 호스트에서는 splitF64(x)로 값을 패킹합니다. 이 함수는 셰이더가 읽는 순서 그대로
[hi, lo] 쌍을 돌려줍니다. 버텍스 스테이지에서 프래그먼트 스테이지로 보간해 넘기는 값, 곧
varying은 f64일 수 없습니다. 삼각형 위에서 hi/lo 쌍을 보간하면 수치적으로 틀린 값이 나오기
때문에 SD0044 오류가 납니다. 대신 f32 값을 보간하거나, 값을 유니폼으로 넘기고 f64 산술은
프래그먼트 스테이지에서 하십시오.
import { splitF64 } from 'typeshade'
const [hi, lo] = splitF64(-8_234_567.890123)new Float32Array(uniformBuffer, 0, 2).set([hi, lo])벡터
vec2f64T, vec3f64T, vec4f64T는 벡터 타입이며, vec2f64(x, y)를 비롯한 같은 꼴의
함수로 만듭니다. 성분 접근과 스위즐, f64·f32·숫자를 브로드캐스트하는 성분별 산술, 성분별
내장 함수 abs, min, max, mix, floor, fract, sin, cos, normalize, 그리고
f64 값 하나로 줄이는 dot, length, distance까지, 모두 f32 버전과 같은 모양으로 씁니다.
이 목록 밖의 연산은 이번에도 SD0041 오류가 나므로, toF32(v.x)처럼 성분 하나씩
좁혀야 합니다. 벡터는 hi 평면과 lo 평면을 담은 구조체로 하향 변환되므로, 성분별 연산은
벡터 전체를 한 번에 처리합니다.
import { dot, f64T, fn, toF32, uniformStruct, vec2f64, vec2f64T } from 'typeshade'
const P = uniformStruct('P', { group: 0, binding: 1, as: 'p' }, { center: vec2f64T })
const d = fn('d', { x: f64T, y: f64T }, (p) => { const q = vec2f64(p.x, p.y).sub(P.field.center) return toF32(dot(q, q)) // the subtraction and the dot both run in f64})벡터 유니폼 필드는 구조체와 같은 레이아웃을 차지합니다. std140에서 성분이 둘이면 16바이트,
셋이나 넷이면 32바이트입니다. 벡터 버텍스 속성은 거부됩니다. hi와 lo를 vecN<f32> 로케이션
두 개로 나눠 넘기고, 성분마다 f64FromParts로 다시 f64 값을 만드십시오.
비용
f64 연산 하나는 f32 연산 여러 개에 해당하며, 곱셈이나 덧셈은 열 배 이상 걸리기도 합니다. 그러므로 f64는 값마다 따로 골라 쓰십시오. 넓은 범위가 필요한 좌표만 f64로 들고 있다가, 차이가 f32로 감당할 만큼 작아지면 바로 좁히고, 나머지 셰이더는 f32 속도로 실행되게 두십시오.
import { f64T, fn, toF32 } from 'typeshade'
// The subtraction needs the range; the shading after it does not.const shade = fn('shade', { world: f64T, camera: f64T }, (p) => toF32(p.world.sub(p.camera)).mul(0.5).add(0.5),)f64 곱셈 가운데 두 가지는 코드를 달리 쓰지 않아도 비용이 덜 듭니다. x.mul(x)는 제곱으로
처리되어 일반 곱셈보다 30%쯤 저렴합니다. 단, x를 계산할 때 스토리지 바인딩에 쓰는 호출
같은 부수 효과가 없어야 합니다. 2.0, 0.5, -4.0처럼 이진 거듭제곱 리터럴로 곱하거나
나누면 f32 워드 두 개의 크기만 바꿉니다. 오버플로와 언더플로만 없다면 결과는 정확합니다.
f32 연산 두 번이면 되고, 1.0에는 연산이 필요 없으며, -1.0에는 부호만 바꿉니다. 값을
키울 수 있는 스케일(배율이 1보다 큰 경우)은 실행 시간에 계산되는 값에만 적용됩니다. 상수는 일반
곱셈을 그대로 씁니다. WGSL은 셰이더를 만들 때 상수를 미리 계산하고 오버플로하는 상수를
거부하는데, 그런 상황을 만들지 않기 위해서입니다.
갤러리의 예제 중 하나인 examples/fp64-deep-zoom.ts는 같은 수식을 두 타입으로 나란히
실행해서, f32 쪽은 평평한 필드로 뭉개지는 반면 f64 쪽은 줄무늬를 그대로 유지하는 모습을
보여줍니다.