atmosphere.wgsl

// 地球の球殻大気。距離はm、係数は1/m。RGBの簡略モデルで、オゾンと多重散乱は含めない。
const EARTH_RADIUS: f32 = 6371000.0;
const ATMOSPHERE_RADIUS: f32 = 6471000.0;
const RAYLEIGH: vec3f = vec3f(5.802e-6, 13.558e-6, 33.1e-6);
const MIE_SCATTER: f32 = 3.996e-6;
const MIE_EXTINCTION: f32 = 4.44e-6;
const SUN_IRRADIANCE: f32 = 20.0;
const VIEW_STEPS: u32 = 16u;
const SUN_STEPS: u32 = 8u;
struct AtmosphereSample {
    transmittance: vec3f,
    scattering: vec3f,
}
fn sphereInterval(origin: vec3f, ray: vec3f, radius: f32) -> vec2f {
    let b = dot(origin, ray);
    let r = length(origin);
    // r*r-radius*radiusの大きな値同士の差を避ける。
    let c = (r - radius) * (r + radius);
    let discriminant = b * b - c;
    if (discriminant < 0.0) { return vec2f(-1.0); }
    let distance = sqrt(discriminant);
    return vec2f(-b - distance, -b + distance);
}
fn atmosphereDensity(position: vec3f) -> vec2f {
    let height = max(length(position) - EARTH_RADIUS, 0.0);
    return exp(-height / vec2f(8000.0, 1200.0));
}
fn atmosphereExtinction(density: vec2f) -> vec3f {
    return RAYLEIGH * density.x + vec3f(MIE_EXTINCTION * density.y);
}
fn solarTransmittance(position: vec3f, sun: vec3f) -> vec3f {
    let ground = sphereInterval(position, sun, EARTH_RADIUS);
    if (ground.x > 0.0) { return vec3f(0.0); }
    let top = sphereInterval(position, sun, ATMOSPHERE_RADIUS);
    let start = max(top.x, 0.0);
    let end = max(top.y, start);
    var opticalDepth = vec2f(0.0);
    for (var i = 0u; i < SUN_STEPS; i++) {
        let a = f32(i) / f32(SUN_STEPS);
        let b = f32(i + 1u) / f32(SUN_STEPS);
        let t0 = mix(start, end, a * a);
        let t1 = mix(start, end, b * b);
        opticalDepth += atmosphereDensity(position + sun * (0.5 * (t0 + t1))) * (t1 - t0);
    }
    return exp(-atmosphereExtinction(opticalDepth));
}
fn integrateAtmosphere(origin: vec3f, ray: vec3f, sun: vec3f, maximumDistance: f32) -> AtmosphereSample {
    let top = sphereInterval(origin, ray, ATMOSPHERE_RADIUS);
    let start = max(top.x, 0.0);
    var end = min(top.y, maximumDistance);
    let ground = sphereInterval(origin, ray, EARTH_RADIUS);
    if (ground.x > 0.0) { end = min(end, ground.x); }
    if (end <= start) { return AtmosphereSample(vec3f(1.0), vec3f(0.0)); }
    let mu = clamp(dot(ray, sun), -1.0, 1.0);
    let rayleighPhase = 3.0 * (1.0 + mu * mu) / (16.0 * 3.14159265);
    let g = 0.8;
    let miePhase = (1.0 - g * g) / (12.56637061 * pow(1.0 + g * g - 2.0 * g * mu, 1.5));
    var transmittance = vec3f(1.0);
    var scattering = vec3f(0.0);
    for (var i = 0u; i < VIEW_STEPS; i++) {
        // 観測点付近の高密度層へ多めにサンプルを割り当てる。
        let a = f32(i) / f32(VIEW_STEPS);
        let b = f32(i + 1u) / f32(VIEW_STEPS);
        let t0 = mix(start, end, a * a);
        let t1 = mix(start, end, b * b);
        let point = origin + ray * (0.5 * (t0 + t1));
        let density = atmosphereDensity(point);
        let extinction = atmosphereExtinction(density);
        let segmentT = exp(-extinction * (t1 - t0));
        let source = SUN_IRRADIANCE * solarTransmittance(point, sun)
            * (RAYLEIGH * density.x * rayleighPhase + vec3f(MIE_SCATTER * density.y * miePhase));
        // 区間内の密度・光源項を一定としたBeer-Lambert積分。
        scattering += transmittance * source * (1.0 - segmentT) / max(extinction, vec3f(1e-9));
        transmittance *= segmentT;
    }
    return AtmosphereSample(transmittance, scattering);
}

camera.wgsl

struct CameraFrame {
    preExposure: f32,
    inversePreExposure: f32,
    previousPreExposure: f32,
    historyToCurrent: f32,
}

// 照明・散乱をシーンリニアで合成した後、HDRターゲットへ書く直前に適用する。
fn preExposeRadiance(radiance: vec3f, frame: CameraFrame) -> vec3f {
    return radiance * frame.preExposure;
}

compact-subgroup.wgsl

// main.tsが共有宣言より前にenable subgroupsを付加する。
@compute @workgroup_size(64)
fn cullMain(@builtin(global_invocation_id) gid: vec3u) {
    let descriptor = packCandidate(gid.x);
    let accepted = tileVisible(gid.x, descriptor);
    let flag = select(0u, 1u, accepted);

    // 不可視・範囲外のlaneも、すべてのsubgroup集約処理に参加する。
    let rank = subgroupExclusiveAdd(flag);
    let count = subgroupAdd(flag);
    let leader = subgroupElect();
    var base = 0u;
    if (leader && count > 0u) {
        base = atomicAdd(&arguments.instanceCount, count);
    }
    base = subgroupBroadcastFirst(base);
    if (accepted) {
        visible[base + rank] = descriptor;
    }
}

compact-workgroup.wgsl

// subgroup未対応時の経路。共有メモリでBlelloch scanを行い、局所Atomicは使わない。
var<workgroup> scan: array<u32, 64>;
var<workgroup> groupBase: u32;

@compute @workgroup_size(64)
fn cullMain(@builtin(global_invocation_id) gid: vec3u, @builtin(local_invocation_index) lane: u32) {
    let descriptor = packCandidate(gid.x);
    let accepted = tileVisible(gid.x, descriptor);
    scan[lane] = select(0u, 1u, accepted);
    workgroupBarrier();

    // 上向きの集約で可視数を求める。すべてのlaneが各バリアへ到達する。
    for (var stride = 1u; stride < 64u; stride *= 2u) {
        let i = (lane + 1u) * stride * 2u - 1u;
        if (i < 64u) { scan[i] += scan[i - stride]; }
        workgroupBarrier();
    }
    if (lane == 0u) {
        let count = scan[63];
        scan[63] = 0u;
        groupBase = 0u;
        if (count > 0u) { groupBase = atomicAdd(&arguments.instanceCount, count); }
    }
    workgroupBarrier();

    // 下向きの展開で、各laneより前の可視数を出力先オフセットとして求める。
    for (var stride = 32u; stride > 0u; stride /= 2u) {
        let i = (lane + 1u) * stride * 2u - 1u;
        if (i < 64u) {
            let left = scan[i - stride];
            scan[i - stride] = scan[i];
            scan[i] += left;
        }
        workgroupBarrier();
    }
    if (accepted) {
        visible[groupBase + scan[lane]] = descriptor;
    }
}

cull.wgsl

@group(0) @binding(1) var<storage, read_write> visible: array<u32>;

// drawIndexedIndirectの20バイト引数に一致させる。可視数だけをAtomic変数とする。
struct DrawArgs {
    indexCount: u32,
    instanceCount: atomic<u32>,
    firstIndex: u32,
    baseVertex: i32,
    firstInstance: u32,
}
@group(0) @binding(2) var<storage, read_write> arguments: DrawArgs;

// laneの可視判定関数は早期returnできるが、呼出元の集約処理からは早期returnしない。
fn tileVisible(id: u32, descriptor: u32) -> bool {
    if (id >= scene.layoutInfo.z) { return false; }
    let tile = unpackTile(descriptor);
    let c = scene.cascades[tile.level];
    // 細かい側の境界補正による移動を含むよう、3セル分の保守的な余裕を取る。
    let lo = c.xy + vec2f(tile.coord) * 64.0 * c.z - vec2f(3.0 * c.z);
    let hi = c.xy + vec2f(tile.coord + vec2i(1)) * 64.0 * c.z + vec2f(3.0 * c.z);
    let center = vec3f((lo.x + hi.x) * 0.5, (scene.terrain.y + scene.terrain.z) * 0.5, (lo.y + hi.y) * 0.5);
    let extent = vec3f((hi.x - lo.x) * 0.5, (scene.terrain.z - scene.terrain.y) * 0.5, (hi.y - lo.y) * 0.5);
    if (scene.layoutInfo.w != 0u) {
        for (var p = 0u; p < 6u; p++) {
            let plane = scene.planes[p];
            if (dot(plane.xyz, center) + plane.w + dot(abs(plane.xyz), extent) < 0.0) {
                return false;
            }
        }
    }
    return true;
}

generate-height.wgsl

struct HeightJob {
    rect: vec4i, // 世界格子での原点X/Zと矩形の幅/高さ。
    config: vec4u, // 標高段、uintシード、予約、予約。
    shape: vec4f, // サンプル間隔、標高振幅、山のスケール、予約。
}
@group(0) @binding(0) var<uniform> job: HeightJob;
@group(0) @binding(1) var<storage, read_write> heights: array<u32>;

fn hashCell(cell: vec2i, seed: u32) -> f32 {
    var h = (u32(cell.x) * 1597334677u) ^ (u32(cell.y) * 3812015801u) ^ (seed * 2798796415u);
    h = (h ^ (h >> 16u)) * 2246822519u;
    h = (h ^ (h >> 13u)) * 3266489917u;
    h ^= h >> 16u;
    return f32(h >> 8u) / 16777215.0;
}

fn valueNoise(p: vec2f, seed: u32) -> f32 {
    let cell = vec2i(floor(p));
    let t = fract(p);
    let f = t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
    return mix(mix(hashCell(cell, seed), hashCell(cell + vec2i(1, 0), seed), f.x),
        mix(hashCell(cell + vec2i(0, 1), seed), hashCell(cell + vec2i(1, 1), seed), f.x), f.y);
}

fn terrainHeight(worldXZ: vec2f) -> f32 {
    let scale = job.shape.z;
    let seed = job.config.y;
    // 低周波の座標変形で、規則的なノイズの山並みを曲げる。
    let warp = vec2f(valueNoise(worldXZ / (scale * 1.7) + vec2f(7.3, 2.1), seed + 11u),
        valueNoise(worldXZ / (scale * 1.7) + vec2f(-3.1, 9.7), seed + 23u));
    let p = worldXZ + (warp - 0.5) * scale * 0.25;
    let broadHeight = valueNoise(p / (scale * 2.0), seed + 37u) - 0.5;
    var frequency = 1.0 / scale;
    var amplitude = 0.5;
    var detail = 0.0;
    for (var octave = 0u; octave < 9u; octave++) {
        // 波長8〜16サンプルの帯域を滑らかに落とし、それ以上の高周波は生成しない。
        // 判定値は同じdispatch内で共通なので、各地点のノイズ値では反復回数を変えない。
        let weight = 1.0 - smoothstep(0.0625, 0.125, job.shape.x * frequency);
        if (weight <= 0.0) { break; }
        let n = valueNoise(p * frequency, seed + 101u + octave * 17u);
        let ridge = 1.0 - abs(2.0 * n - 1.0);
        detail += (ridge * ridge - 0.5) * amplitude * weight;
        frequency *= 2.0;
        amplitude *= 0.5;
    }
    return job.shape.y * (broadHeight * 0.75 + detail * 0.7);
}

@compute @workgroup_size(8, 8)
fn generateMain(@builtin(global_invocation_id) gid: vec3u) {
    if (gid.x >= u32(job.rect.z) / 2u || gid.y >= u32(job.rect.w)) { return; }
    let cell = job.rect.xy + vec2i(i32(gid.x * 2u), i32(gid.y));
    let first = terrainHeight(vec2f(cell) * job.shape.x);
    let second = terrainHeight(vec2f(cell + vec2i(1, 0)) * job.shape.x);
    let physical = vec2u(cell) & vec2u(1023u);
    let word = job.config.x * 524288u + physical.y * 512u + physical.x / 2u;
    // 2個のFP16を1語として書く。他の矩形・invocationと書込先が重ならず、Atomic不要。
    heights[word] = pack2x16float(vec2f(first, second));
}

post-process.wgsl

@group(0) @binding(0) var sceneHdr: texture_2d<f32>;

@vertex
fn postVertex(@builtin(vertex_index) id: u32) -> @builtin(position) vec4f {
    let xy = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
    return vec4f(xy[id], 0.0, 1.0);
}

fn linearToSrgb(linear: vec3f) -> vec3f {
    // Canvasの非sRGB形式へ書くため、表示用のsRGB変換をここで一度だけ行う。
    return select(1.055 * pow(linear, vec3f(1.0 / 2.4)) - 0.055,
        12.92 * linear, linear <= vec3f(0.0031308));
}

// NarkowiczのACES Filmicフィット(CC0)。厳密なACES表示変換ではない。
// 出典: https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/
fn acesFilmic(linearHdr: vec3f) -> vec3f {
    let numerator = linearHdr * (2.51 * linearHdr + 0.03);
    let denominator = linearHdr * (2.43 * linearHdr + 0.59) + 0.14;
    return clamp(numerator / denominator, vec3f(0.0), vec3f(1.0));
}

@fragment
fn postFragment(@builtin(position) pixel: vec4f) -> @location(0) vec4f {
    let preExposedHdr = max(textureLoad(sceneHdr, vec2i(pixel.xy), 0).rgb, vec3f(0.0));
    // HDRはすでにカメラのPreExposureが適用済み。ここでは再適用しない。
    let mapped = acesFilmic(preExposedHdr);
    return vec4f(linearToSrgb(mapped), 1.0);
}

sky-common.wgsl

struct SkyParameters {
    sun: vec4f,
    observer: vec4f, // 海面基準高度m、予約。
}
@group(0) @binding(0) var<uniform> sky: SkyParameters;

// ピクセルレイ背景とOctahedral生成で同じ地球大気を評価する。
fn skyRadiance(direction: vec3f) -> vec3f {
    let origin = vec3f(0.0, EARTH_RADIUS + max(sky.observer.x, 2.0), 0.0);
    let sample = integrateAtmosphere(origin, direction, sky.sun.xyz, 1e9);
    var groundRadiance = vec3f(0.0);
    let ground = sphereInterval(origin, direction, EARTH_RADIUS);
    if (ground.x > 0.0) {
        let point = origin + direction * ground.x;
        let normal = normalize(point);
        groundRadiance = vec3f(0.08) * SUN_IRRADIANCE * solarTransmittance(point + normal * 2.0, sky.sun.xyz)
            * max(dot(normal, sky.sun.xyz), 0.0) / 3.14159265;
    }
    // 鋭い太陽円盤は含めない。直射光はDirectional Lightへ分離する。
    return sample.scattering + sample.transmittance * groundRadiance;
}

sky-debug.wgsl

@group(0) @binding(0) var skyMap: texture_2d<f32>;
@group(0) @binding(1) var<uniform> cameraFrame: CameraFrame;
struct DebugVertex {
    @builtin(position) clip: vec4f,
    @location(0) uv: vec2f,
}
@vertex
fn debugVertex(@builtin(vertex_index) id: u32) -> DebugVertex {
    let p = array<vec2f, 3>(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3))[id];
    // 行0を上に表示する。+Z極のMapに対する軸交換や追加のV反転は行わない。
    return DebugVertex(vec4f(p, 0, 1), p * vec2f(0.5, -0.5) + 0.5);
}
@fragment
fn debugFragment(input: DebugVertex) -> @location(0) vec4f {
    let size = textureDimensions(skyMap);
    let pixel = clamp(vec2i(input.uv * vec2f(size)), vec2i(0), vec2i(size) - 1);
    let radiance = textureLoad(skyMap, pixel, 0).rgb;
    // 環境マップ自体は未露出。デバッグ表示のためのコピーにだけPreExposureを掛ける。
    return vec4f(preExposeRadiance(radiance, cameraFrame), 1);
}

sky-generate.wgsl

@group(0) @binding(1) var skyMap: texture_storage_2d<rgba16float, write>;
@compute @workgroup_size(8, 8)
fn generateSky(@builtin(global_invocation_id) id: vec3u) {
    let size = textureDimensions(skyMap);
    if (any(id.xy >= size)) { return; }
    let uv = (vec2f(id.xy) + 0.5) / vec2f(size);
    // +Z極のOctahedral UVから、Y-up世界の方向をそのまま復号する。
    let direction = octahedralDirection(uv);
    textureStore(skyMap, vec2i(id.xy), vec4f(skyRadiance(direction), 1.0));
}

sky-octahedral.wgsl

// PBRT・Unity等で用いられる、+Zを極としてXYへ展開する標準的なOctahedral復号。
// ワールドのY-upとは独立したテクスチャの規約。方向を並べ替えず、ワールドXYZで返す。
// UV中心=+Z、四隅=-Z、右辺中央=+X、左辺中央=-X。
// テクスチャのVは下向きなので、画像上辺中央=-Y、下辺中央=+Yとなる。
// 生成・SH投影はこの原本を共有し、表示側で独自の軸交換やV反転を加えない。
// 参考: https://www.pbr-book.org/4ed/Geometry_and_Transformations/Spherical_Geometry
fn octahedralVector(uv: vec2f) -> vec3f {
    let p = uv * 2.0 - 1.0;
    var v = vec3f(p.x, p.y, 1.0 - abs(p.x) - abs(p.y));
    if (v.z < 0.0) {
        let signs = select(vec2f(-1.0), vec2f(1.0), v.xy >= vec2f(0.0));
        v = vec3f((1.0 - abs(v.y)) * signs.x, (1.0 - abs(v.x)) * signs.y, v.z);
    }
    return v;
}
fn octahedralDirection(uv: vec2f) -> vec3f {
    return normalize(octahedralVector(uv));
}
fn octahedralWeight(uv: vec2f) -> f32 {
    let v = octahedralVector(uv);
    // 単位球へ放射投影するJacobianは1/|v|^3。最終段で総立体角を4piに正規化する。
    return 1.0 / pow(dot(v, v), 1.5);
}

sky-project.wgsl

@group(0) @binding(0) var skyMap: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> partials: array<vec4f>;
var<workgroup> sums: array<vec4f, SKY_SH_COEFFICIENTS * 64u>;

@compute @workgroup_size(64)
fn projectSky(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
    let size = textureDimensions(skyMap);
    let index = group.x * 64u + lane;
    let pixel = vec2u(index % size.x, index / size.x);
    var radiance = vec3f(0.0);
    var basis: array<f32, 9>;
    var weight = 0.0;
    if (pixel.y < size.y) {
        let uv = (vec2f(pixel) + 0.5) / vec2f(size);
        weight = octahedralWeight(uv);
        basis = shBasis(octahedralDirection(uv));
        radiance = textureLoad(skyMap, vec2i(pixel), 0).rgb;
    }
    for (var c = 0u; c < SKY_SH_COEFFICIENTS; c++) {
        // 係数0のWだけに立体角の重み和を持たせる。
        sums[c * 64u + lane] = vec4f(radiance * basis[c] * weight, select(0.0, weight, c == 0u));
    }
    workgroupBarrier();
    for (var stride = 32u; stride > 0u; stride /= 2u) {
        if (lane < stride) {
            for (var c = 0u; c < SKY_SH_COEFFICIENTS; c++) {
                sums[c * 64u + lane] += sums[c * 64u + lane + stride];
            }
        }
        workgroupBarrier();
    }
    if (lane == 0u) {
        for (var c = 0u; c < SKY_SH_COEFFICIENTS; c++) { partials[group.x * SKY_SH_COEFFICIENTS + c] = sums[c * 64u]; }
    }
}

sky-reduce.wgsl

@group(0) @binding(0) var<storage, read> partials: array<vec4f>;
@group(0) @binding(1) var<storage, read_write> result: SkySH;
var<workgroup> sums: array<vec4f, SKY_SH_COEFFICIENTS * 64u>;

@compute @workgroup_size(64)
fn reduceSky(@builtin(local_invocation_index) lane: u32) {
    let groups = arrayLength(&partials) / SKY_SH_COEFFICIENTS;
    for (var c = 0u; c < SKY_SH_COEFFICIENTS; c++) {
        var sum = vec4f(0.0);
        for (var i = lane; i < groups; i += 64u) { sum += partials[i * SKY_SH_COEFFICIENTS + c]; }
        sums[c * 64u + lane] = sum;
    }
    workgroupBarrier();
    for (var stride = 32u; stride > 0u; stride /= 2u) {
        if (lane < stride) {
            for (var c = 0u; c < SKY_SH_COEFFICIENTS; c++) { sums[c * 64u + lane] += sums[c * 64u + lane + stride]; }
        }
        workgroupBarrier();
    }
    if (lane == 0u) {
        let normalization = 12.5663706144 / sums[0].w;
        for (var c = 0u; c < SKY_SH_COEFFICIENTS; c++) {
            // Lambertの1/piを含む帯域係数はl=0で1、l=1で2/3、l=2で1/4。
            let kernel = select(select(0.25, 0.6666666667, c < 4u), 1.0, c == 0u);
            result.coefficients[c] = vec4f(sums[c * 64u].rgb * normalization * kernel, 0.0);
        }
    }
}

sky-render.wgsl

struct SkyView {
    forward: vec4f,
    right: vec4f, // 水平のtan(FOV/2)を含む。
    up: vec4f, // 垂直のtan(FOV/2)を含む。
}
@group(0) @binding(1) var<uniform> view: SkyView;
@group(0) @binding(2) var<uniform> cameraFrame: CameraFrame;
struct SkyVertex {
    @builtin(position) clip: vec4f,
    @location(0) ndc: vec2f,
}
@vertex
fn skyVertex(@builtin(vertex_index) id: u32) -> SkyVertex {
    let xy = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
    // Reversed Zの最遠方。既存の地形深度がある画素には描画しない。
    return SkyVertex(vec4f(xy[id], 0.0, 1.0), xy[id]);
}
@fragment
fn skyFragment(input: SkyVertex) -> @location(0) vec4f {
    let ray = normalize(view.forward.xyz + input.ndc.x * view.right.xyz + input.ndc.y * view.up.xyz);
    // 背景はOctahedral Mapを引かず、ピクセルごとに同じ天球関数を直接評価する。
    let linearHdr = skyRadiance(ray);
    // 天球もForwardと同じHDRターゲットへ線形放射輝度を出力する。
    return vec4f(preExposeRadiance(linearHdr, cameraFrame), 1.0);
}

sky-sh.wgsl

const SKY_SH_COEFFICIENTS: u32 = 9u;
struct SkySH {
    coefficients: array<vec4f, 9>, // Lambert畳み込みと1/piを適用済みのRGB係数。Wは予約。
}

// Y-upの実SH正規直交基底。l=0,1,2の順に投影・評価を統一する。
fn shBasis(direction: vec3f) -> array<f32, 9> {
    let x = direction.x;
    let y = direction.y;
    let z = direction.z;
    return array<f32, 9>(
        0.2820947918,
        0.4886025119 * x,
        0.4886025119 * y,
        0.4886025119 * z,
        1.0925484306 * x * y,
        1.0925484306 * y * z,
        0.3153915653 * (3.0 * y * y - 1.0),
        1.0925484306 * x * z,
        0.5462742153 * (x * x - z * z)
    );
}

fn evaluateSkySH(coefficients: SkySH, normal: vec3f) -> vec3f {
    let basis = shBasis(normal);
    var value = vec3f(0.0);
    for (var c = 0u; c < SKY_SH_COEFFICIENTS; c++) {
        value += coefficients.coefficients[c].rgb * basis[c];
    }
    // 有限次数への打切りで負値が出る方向だけ0へ抑える。
    return max(value, vec3f(0.0));
}

terrain-forward.wgsl

struct VertexOutput {
    @builtin(position) @invariant clip: vec4f,
    @location(0) normal: vec3f,
    @location(1) height: f32,
    @location(2) @interpolate(flat) tileID: u32,
    @location(3) worldPosition: vec3f,
}
struct Lighting {
    shadowViewProjection: mat4x4f,
    light: vec4f, // 地表から光源への方向、直射光強度。
    shadow: vec4f, // 有効、比較時深度バイアス、法線バイアスm、1/解像度。
    shadowFilter: vec4f, // PCF半径、影の可視化、予約、予約。
    skyDiffuse: vec4f, // RGBは固定アルベド、WはSH Diffuse強度。
    cameraPosition: vec4f, // ワールドカメラXYZ、Wは予約。
    atmosphere: vec4f, // 大気遠近法有効、ワールドY=0の海面高度m、固定アルベド有効、予約。
}
@group(1) @binding(0) var<uniform> lighting: Lighting;
@group(1) @binding(1) var shadowMap: texture_depth_2d;
@group(1) @binding(2) var shadowSampler: sampler_comparison;
@group(1) @binding(3) var<uniform> skySH: SkySH;
@group(1) @binding(4) var<uniform> cameraFrame: CameraFrame;

// 標高キャッシュの段を識別する固定色。メッシュLOD0/1は同じ標高段0の色となる。
fn heightCascadeColor(layer: u32) -> vec3f {
    let colors = array<vec3f, 7>(
        vec3f(0.85, 0.18, 0.15), vec3f(0.95, 0.55, 0.10),
        vec3f(0.75, 0.85, 0.12), vec3f(0.12, 0.75, 0.32),
        vec3f(0.10, 0.65, 0.90), vec3f(0.32, 0.25, 0.90), vec3f(0.80, 0.15, 0.65));
    return colors[min(layer, 6u)];
}

@vertex
fn vertexMain(@builtin(vertex_index) id: u32, @builtin(instance_index) instance: u32) -> VertexOutput {
    let surface = terrainVertex(id, instance);
    if (scene.domain.w != 0.0 || scene.terrain.w != 0.0) {
        // 診断時は法線属性を色として再利用し、補間属性を増やさない。法線用の標高参照も省く。
        let color = mix(heightCascadeColor(surface.layer), heightCascadeColor(surface.coarseLayer), surface.weight);
        // 診断では高さ属性を混合率、タイルID属性をpacked descriptorとして再利用する。
        return VertexOutput(surface.clip, color, surface.weight, visible[instance], vec3f(surface.xz.x, surface.height, surface.xz.y));
    }
    // 外周では法線参照の間隔も粗い段へ合わせ、隣接する段との陰影差を抑える。
    let spacing = mix(f32(1u << surface.layer), f32(1u << surface.coarseLayer), surface.weight);
    let dx = (blendedHeight(surface.xz + vec2f(spacing, 0.0), surface.layer, surface.coarseLayer, surface.weight)
        - blendedHeight(surface.xz - vec2f(spacing, 0.0), surface.layer, surface.coarseLayer, surface.weight)) / (2.0 * spacing);
    let dz = (blendedHeight(surface.xz + vec2f(0.0, spacing), surface.layer, surface.coarseLayer, surface.weight)
        - blendedHeight(surface.xz - vec2f(0.0, spacing), surface.layer, surface.coarseLayer, surface.weight)) / (2.0 * spacing);
    return VertexOutput(surface.clip, normalize(vec3f(-dx, 1.0, -dz)), surface.height, surface.tileID, vec3f(surface.xz.x, surface.height, surface.xz.y));
}

@vertex
fn wireVertexMain(@builtin(vertex_index) id: u32, @builtin(instance_index) instance: u32) -> VertexOutput {
    var surface = terrainVertex(id, instance);
    surface.clip.z *= 1.00001; // Reversed Zでデバッグ線を少し手前へ出す。
    return VertexOutput(surface.clip, vec3f(0.0, 1.0, 0.0), surface.height, surface.tileID, vec3f(surface.xz.x, surface.height, surface.xz.y));
}

fn tileColor(id: u32) -> vec3f {
    var h = id * 747796405u + 2891336453u;
    h = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u;
    h = (h >> 22u) ^ h;
    return 0.25 + 0.65 * vec3f(f32(h & 255u), f32((h >> 8u) & 255u), f32((h >> 16u) & 255u)) / 255.0;
}

// 基本の比較サンプルをPCFで平均する。投影範囲外はこの1枚では影を扱わない。
fn shadowVisibility(position: vec3f, normal: vec3f) -> f32 {
    if (lighting.shadow.x == 0.0) { return 1.0; }
    let offset = position + normal * lighting.shadow.z;
    let clip = lighting.shadowViewProjection * vec4f(offset, 1.0);
    let uv = clip.xy * vec2f(0.5, -0.5) + 0.5;
    let z = clip.z;
    let radius = i32(lighting.shadowFilter.x);
    let margin = (f32(radius) + 1.0) * lighting.shadow.w;
    if (any(uv < vec2f(margin)) || any(uv > vec2f(1.0 - margin)) || z < 0.0 || z > 1.0) {
        return 1.0;
    }
    var sum = 0.0;
    for (var y = -radius; y <= radius; y++) {
        for (var x = -radius; x <= radius; x++) {
            // Reversed Zなので受け手の参照深度を増やして自己遮蔽を緩和する。
            sum += textureSampleCompareLevel(shadowMap, shadowSampler,
                uv + vec2f(f32(x), f32(y)) * lighting.shadow.w, z + lighting.shadow.y);
        }
    }
    let width = f32(2 * radius + 1);
    return sum / (width * width);
}

// 世界原点に固定した16テクセル間隔の格子。循環窓の更新でも位相は動かさない。
fn heightGridLine(xz: vec2f, layer: u32) -> f32 {
    let uv = xz / (16.0 * f32(1u << layer));
    let footprint = max(fwidth(uv), vec2f(0.000001));
    let distance = abs(fract(uv + 0.5) - 0.5);
    let coverage = vec2f(1.0) - smoothstep(footprint * 0.5, footprint * 1.5, distance);
    // 画面上で密になりすぎる格子は消し、遠方のちらつきや塗り潰しを抑える。
    let fade = clamp(1.0 - max(footprint.x, footprint.y) * 2.0, 0.0, 1.0);
    return max(coverage.x, coverage.y) * fade;
}

// 不透明PSにdiscard/clip、深度出力、Storage書込は置かない。
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
    if (scene.domain.w != 0.0 || scene.terrain.w != 0.0) {
        // 法線として正規化せず、標高混合率で補間した段の識別色をそのまま表示する。
        var color = select(vec3f(0.65), input.normal, scene.domain.w != 0.0);
        if (scene.terrain.w != 0.0) {
            let level = (input.tileID >> 19u) & 7u;
            let last = u32(scene.domain.y) - 1u;
            let fine = min(u32(max(i32(level) - 1, 0)), last);
            let coarse = min(level, last);
            // 間隔そのものを連続変化させず、2段の格子を標高と同率で混合する。
            let grid = mix(heightGridLine(input.worldPosition.xz, fine), heightGridLine(input.worldPosition.xz, coarse), input.height);
            color = mix(color, vec3f(0.025), grid * 0.85);
        }
        return vec4f(preExposeRadiance(color, cameraFrame), 1.0);
    }
    let n = normalize(input.normal);
    if (scene.elevation.z != 0.0) { return vec4f(preExposeRadiance(n * 0.5 + 0.5, cameraFrame), 1.0); }
    let visibility = shadowVisibility(input.worldPosition, n);
    if (lighting.shadowFilter.y != 0.0) { return vec4f(preExposeRadiance(vec3f(visibility), cameraFrame), 1.0); }
    let t = clamp((input.height - scene.elevation.x) * scene.elevation.y, 0.0, 1.0);
    if (scene.elevation.w != 0.0) { return vec4f(preExposeRadiance(tileColor(input.tileID), cameraFrame), 1.0); }
    // アルベドはリニア反射率。表示用sRGB値として扱わない。
    // 固定値はsRGB色ではなくリニア反射率。1は白色Lambert面、0は黒色面。
    let albedo = select(vec3f(mix(0.16, 0.94, t)), lighting.skyDiffuse.xyz, lighting.atmosphere.z != 0.0);
    // 直射光のLambert反射。Shadow Mapの可視率はこの直射光だけへ適用する。
    let relative = input.worldPosition - lighting.cameraPosition.xyz;
    let observerHeight = max(lighting.cameraPosition.y + lighting.atmosphere.y, 2.0);
    let origin = vec3f(0.0, EARTH_RADIUS + observerHeight, 0.0);
    let point = origin + relative;
    let sunT = solarTransmittance(point, lighting.light.xyz);
    let direct = lighting.light.w * sunT * max(dot(n, lighting.light.xyz), 0.0) * visibility / 3.14159265;
    // GPUで投影・Lambert畳み込みした2次SHを、ワールド空間の法線で評価する。
    let skyDiffuse = albedo * evaluateSkySH(skySH, n) * lighting.skyDiffuse.w;
    // 直接光とSkyLightをシーンリニアHDRで加算する。1を超える値を保持する。
    var linearHdr = albedo * direct + skyDiffuse;
    if (lighting.atmosphere.x != 0.0) {
        let distance = length(relative);
        if (distance > 0.001) {
            let aerial = integrateAtmosphere(origin, relative / distance, lighting.light.xyz, distance);
            linearHdr = linearHdr * aerial.transmittance + aerial.scattering;
        }
    }
    // Forwardの出力はPreExposure済みの線形HDR。トーンマッピングは後続Passで行う。
    return vec4f(preExposeRadiance(linearHdr, cameraFrame), 1.0);
}
@fragment
fn wireMain() -> @location(0) vec4f {
    return vec4f(preExposeRadiance(vec3f(0.04, 0.055, 0.065), cameraFrame), 1.0);
}

terrain-geometry.wgsl

@group(0) @binding(1) var<storage, read> heightMap: array<u32>;
@group(0) @binding(2) var<storage, read> visible: array<u32>;

struct GridPosition {
    xz: vec2f,
    weight: f32,
}

// これらはVS内部だけの情報で、PSへ渡す補間属性は増やさない。
struct Surface {
    clip: vec4f,
    xz: vec2f,
    height: f32,
    tileID: u32,
    layer: u32,
    coarseLayer: u32,
    weight: f32,
}

fn heightTexel(cell: vec2i, layer: u32) -> f32 {
    let physical = vec2u(cell) & vec2u(1023u);
    let word = layer * 524288u + physical.y * 512u + physical.x / 2u;
    let pair = unpack2x16float(heightMap[word]);
    return select(pair.x, pair.y, (physical.x & 1u) != 0u);
}

fn sampleHeight(xz: vec2f, layer: u32) -> f32 {
    let pixel = xz / f32(1u << layer);
    let lower = vec2i(floor(pixel));
    let f = fract(pixel);
    // 世界格子を循環先へ写すだけで、窓原点を跨いでも地形の位相を変えない。
    // 必要領域が窓内へ収まることはCPU側の範囲・補間余裕テストで確認する。
    let a = heightTexel(lower, layer);
    let b = heightTexel(lower + vec2i(1, 0), layer);
    let c = heightTexel(lower + vec2i(0, 1), layer);
    let d = heightTexel(lower + vec2i(1, 1), layer);
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

fn blendedHeight(xz: vec2f, layer: u32, coarse: u32, weight: f32) -> f32 {
    let fineHeight = sampleHeight(xz, layer);
    if (weight > 0.0 && layer != coarse) {
        // 高周波除去で段ごとに標高が異なるため、XZだけでなく標高も粗い段へ一致させる。
        return mix(fineHeight, sampleHeight(xz, coarse), weight);
    }
    return fineHeight;
}

// 全タイルで通常格子を共有する。packed情報を使い、VSごとのリング判定を省く。
// 2セルスナップの原点差を外周8行で吸収し、粗格子の点へ位置と標高を一致させる。
fn worldXZ(id: u32, tile: Tile) -> GridPosition {
    let c = scene.cascades[tile.level];
    let grid = tile.coord * 64 + vec2i(i32(id % 65u), i32(id / 65u));
    var xz = c.xy + vec2f(grid) * c.z;
    var weight = 0.0;
    if (tile.mask != 0u) {
        let coarse = scene.cascades[tile.level + 1u];
        // 接続先の粗格子点を共有する頂点は同じ重みで動かす。
        // 元の頂点位置ごとに重みを変えると、2セル移動時に角の面が反転・非縮退化する。
        let coarseXZ = coarse.xy + vec2f((grid >> vec2u(1u)) * 2) * c.z;
        let distance = max(abs(coarseXZ.x - coarse.x), abs(coarseXZ.y - coarse.y));
        weight = clamp((distance - (c.w - 8.0 * c.z)) / (8.0 * c.z), 0.0, 1.0);
        xz = mix(xz, coarseXZ, weight);
    }
    return GridPosition(xz, weight);
}

// タイル描画とワイヤーフレームで位置補正と標高取得を共有する。ワイヤーフレーム用には法線を計算しない。
fn terrainVertex(id: u32, instance: u32) -> Surface {
    let descriptor = visible[instance];
    let tile = unpackTile(descriptor);
    let grid = worldXZ(id, tile);
    let last = u32(scene.domain.y) - 1u;
    let layer = min(u32(max(i32(tile.level) - 1, 0)), last);
    let coarse = min(tile.level, last);
    let h = blendedHeight(grid.xz, layer, coarse, grid.weight);
    let position = vec3f(grid.xz.x, h, grid.xz.y);
    return Surface(scene.viewProjection * vec4f(position, 1.0), grid.xz, h,
        descriptor & 2047u, layer, coarse, grid.weight);
}

terrain-zonly.wgsl

// 不透明物のZOnlyバリエーション。メインと光源はgroup(0)の視点だけを変える。
// Forwardと同じ位置関数を使い、Equal深度比較のため位置出力にinvariantを指定する。
@vertex
fn depthMain(@builtin(vertex_index) id: u32, @builtin(instance_index) instance: u32)
    -> @builtin(position) @invariant vec4f {
    return terrainVertex(id, instance).clip;
}

tile-common.wgsl

// 共有Uniformは480バイト。各項目のバイト位置はmain.tsを参照。
struct Scene {
    viewProjection: mat4x4f,
    planes: array<vec4f, 6>,
    light: vec4f,
    elevation: vec4f, // 最低標高、標高幅の逆数、法線表示、タイル色分け。
    domain: vec4f, // 標高窓サイズ、標高段数、最細間隔、Height Clipmap色分け。
    terrain: vec4f, // 標高原点、最低/最高標高の保守的範囲、HeightMapグリッド。
    layoutInfo: vec4u, // 密度、LOD段数、候補数、カリング有効フラグ。
    cascades: array<vec4f, 8>, // 原点X/Z、セル間隔、段の半幅。
    heightWindows: array<vec4i, 7>, // 各標高窓の世界格子原点X/Z、予約、予約。
}
@group(0) @binding(0) var<uniform> scene: Scene;

struct Tile {
    coord: vec2i,
    level: u32,
    mask: u32,
}

// 候補IDからの配置復元はComputeの各laneで1回だけ行い、VSでは繰り返さない。
fn decodeTile(id: u32) -> Tile {
    let n = scene.layoutInfo.x;
    let full = n * n;
    let ring = full * 3u / 4u;
    var level = 0u;
    var k = id;
    if (id >= full) {
        level = 1u + (id - full) / ring;
        k = (id - full) % ring;
    }
    let top = full / 4u;
    let half = n / 2u;
    let quarter = n / 4u;
    var row = 0u;
    var col = 0u;
    if (level == 0u || k < top) {
        row = k / n;
        col = k % n;
    } else if (k < top * 2u) {
        let q = k - top;
        row = quarter + q / half;
        let j = q % half;
        col = select(j + half, j, j < quarter);
    } else {
        let q = k - top * 2u;
        row = quarter * 3u + q / n;
        col = q % n;
    }
    let p = vec2i(vec2u(col, row)) - vec2i(i32(half));
    var mask = 0u;
    // 補正が必要なのは細かい側の外周だけ。最も粗い段の外周は動かさない。
    if (level + 1u < scene.layoutInfo.y) {
        if (p.y == -i32(half)) { mask |= 1u; }
        if (p.x == i32(half) - 1) { mask |= 2u; }
        if (p.y == i32(half) - 1) { mask |= 4u; }
        if (p.x == -i32(half)) { mask |= 8u; }
    }
    return Tile(p, level, mask);
}

// ビット0〜10に元の候補IDを残し、デバッグ色を安定させる。
// ビット11〜14:X+8、15〜18:Z+8、19〜21:LOD段、22〜25:外周辺N/E/S/W。
fn packCandidate(id: u32) -> u32 {
    if (id >= scene.layoutInfo.z) { return 0u; }
    let t = decodeTile(id);
    return id | (u32(t.coord.x + 8) << 11u) | (u32(t.coord.y + 8) << 15u)
        | (t.level << 19u) | (t.mask << 22u);
}

fn unpackTile(value: u32) -> Tile {
    return Tile(
        vec2i(i32((value >> 11u) & 15u) - 8, i32((value >> 15u) & 15u) - 8),
        (value >> 19u) & 7u,
        (value >> 22u) & 15u
    );
}

camera-frame.ts

export interface CameraFrame {
    readonly preExposure: number;
    readonly inversePreExposure: number;
    readonly previousPreExposure: number;
    readonly historyToCurrent: number;
    readonly verticalFov: number;
    readonly uniform: Float32Array<ArrayBuffer>;
}

// EV100は1増えると光量が半分になる。露出補正EVとは符号が逆。
// レンズ透過率による校正係数は1とし、EV100=0でPreExposure=1とする。
// 参考: https://dev.epicgames.com/documentation/en-us/unreal-engine/auto-exposure-in-unreal-engine
// 描画中は換算済みのPreExposureを同じスナップショットとして共有する。
export function cameraFrame(ev100: number, verticalFovDegrees: number, previousPreExposure: number): CameraFrame {
    if (!Number.isFinite(ev100) || ev100 < -10 || ev100 > 15 || !Number.isFinite(previousPreExposure) || previousPreExposure <= 0
        || !Number.isFinite(verticalFovDegrees) || verticalFovDegrees < 30 || verticalFovDegrees > 110) {
        throw new Error('カメラの露出・画角が不正です');
    }
    const exposure = 2 ** -ev100;
    return { preExposure: exposure, inversePreExposure: 1 / exposure, previousPreExposure,
        historyToCurrent: exposure / previousPreExposure, verticalFov: verticalFovDegrees * Math.PI / 180,
        uniform: new Float32Array([exposure, 1 / exposure, previousPreExposure, exposure / previousPreExposure]) };
}

clipmap-grid.ts

export const TILE_CELLS = 64;
export const CORE_VERTICES = 65 * 65;
export const MAX_CASCADES = 8;
export const MAX_CANDIDATES = 16 * 16 + 7 * 16 * 16 * 3 / 4;
export interface Layout {
    density: number;
    levels: number;
    count: number;
    radius: number;
    guaranteed: number;
    steps: number[];
}
export function createLayout(density: number, requested: number): Layout {
    if (![4, 8, 16].includes(density) || !Number.isFinite(requested) || requested < 1 || requested > 16000) {
        throw new Error('密度またはカバー半径が範囲外です');
    }
    let levels = 1;
    while (levels < MAX_CASCADES && (128 - 8 / density) * 2 ** (levels - 1) < requested) {
        levels++;
    }
    const radius = 128 * 2 ** (levels - 1), steps = Array.from({ length: levels }, (_, l) => 4 / density * 2 ** l);
    return { density, levels, count: density * density + (levels - 1) * density * density * 3 / 4, radius, guaranteed: radius - 2 * steps[levels - 1], steps };
}
export function cascadeParameters(x: number, z: number, layout: Layout, snap: boolean): Float32Array<ArrayBuffer> {
    const out = new Float32Array(MAX_CASCADES * 4);
    for (let l = 0; l < layout.levels; l++) {
        const s = layout.steps[l];
        // 交互の対角線を持つIBの周期は2セル。1セル移動では世界上の対角線が反転する。
        // 2セル単位で移動し、世界セルの三角形分割を維持する。
        // 親子の原点差は最大2細セル。接続帯では粗格子の点へ共通の重みで吸収する。
        const period = 2 * s;
        out.set([snap ? Math.floor(x / period) * period : x, snap ? Math.floor(z / period) * period : z, s, 128 * 2 ** l], l * 4);
    }
    return out;
}
export interface Tile {
    x: number;
    z: number;
    level: number;
    mask: number;
}
// トポロジーテスト用のCPU参照実装。WGSLのdecodeTileと同じ整数配置を使う。
export function decodeTile(id: number, density: number, levels = MAX_CASCADES): Tile {
    const full = density * density, ring = full * 3 / 4;
    let level = 0, k = id;
    if (id >= full) {
        level = 1 + Math.floor((id - full) / ring);
        k = (id - full) % ring;
    }
    let row: number, col: number;
    const top = full / 4, mid = full / 4, half = density / 2, quarter = density / 4;
    if (level === 0 || k < top) {
        row = Math.floor(k / density);
        col = k % density;
    }
    else if (k < top + mid) {
        const q = k - top;
        row = quarter + Math.floor(q / half);
        const j = q % half;
        col = j < quarter ? j : j + half;
    }
    else {
        const q = k - top - mid;
        row = 3 * quarter + Math.floor(q / density);
        col = q % density;
    }
    const x = col - half, z = row - half;
    let mask = 0;
    if (level + 1 < levels) {
        if (z === -half) {
            mask |= 1;
        }
        if (x === half - 1) {
            mask |= 2;
        }
        if (z === half - 1) {
            mask |= 4;
        }
        if (x === -half) {
            mask |= 8;
        }
    }
    return { x, z, level, mask };
}
export function createTileIndices(): {
    surface: Uint16Array<ArrayBuffer>;
    wire: Uint16Array<ArrayBuffer>;
    vertexCount: number;
} {
    const values: number[] = [];
    // 8×8セルのブロック順で頂点参照の局所性を保つ。対角線を交互にして、
    // 外周の奇数頂点を集約した後も2:1接続を保つ。一律の対角線では一部の角が反転する。
    for (let bz = 0; bz < 64; bz += 8) {
        for (let bx = 0; bx < 64; bx += 8) {
            for (let z = bz; z < bz + 8; z++) {
                for (let x = bx; x < bx + 8; x++) {
                    const a = z * 65 + x, b = a + 1, c = a + 65, d = c + 1;
                    if ((x + z) % 2 === 0) {
                        values.push(a, c, d, a, d, b);
                    }
                    else {
                        values.push(a, c, b, b, c, d);
                    }
                }
            }
        }
    }
    const edges = new Set<string>(), lines: number[] = [];
    for (let i = 0; i < values.length; i += 3) {
        for (let j = 0; j < 3; j++) {
            const a = values[i + j], b = values[i + (j + 1) % 3], key = a < b ? `${a},${b}` : `${b},${a}`;
            if (!edges.has(key)) {
                edges.add(key);
                lines.push(a, b);
            }
        }
    }
    return { surface: new Uint16Array(values), wire: new Uint16Array(lines), vertexCount: CORE_VERTICES };
}
// 共通VSのCPU参照実装。すべての頂点を通常タイルの格子内に保持する。
// 2セルスナップによる原点差を外周8行で吸収する。
// 同じ接続先へ集まる頂点は共通の重みを使い、立体的な縮退を維持する。
export function tileVertex(id: number, tile: Tile, layout: Layout, origins: Float32Array): {
    x: number;
    z: number;
    weight: number;
} {
    const l = tile.level, s = origins[l * 4 + 2], ox = origins[l * 4], oz = origins[l * 4 + 1];
    const u = tile.x * 64 + id % 65, v = tile.z * 64 + Math.floor(id / 65);
    let x = ox + u * s, z = oz + v * s;
    let weight = 0;
    if (l + 1 < layout.levels) {
        const cx = origins[(l + 1) * 4], cz = origins[(l + 1) * 4 + 1];
        const tx = cx + 2 * Math.floor(u / 2) * s;
        const tz = cz + 2 * Math.floor(v / 2) * s;
        const distance = Math.max(Math.abs(tx - cx), Math.abs(tz - cz));
        weight = Math.max(0, Math.min(1, (distance - (origins[l * 4 + 3] - 8 * s)) / (8 * s)));
        x += weight * (tx - x);
        z += weight * (tz - z);
    }
    return { x, z, weight };
}
// 可視タイルのビット配置:[0:10] 候補ID、[11:14] X+8、[15:18] Z+8、
// [19:21] LOD段、[22:25] 外周辺N/E/S/W。上位6ビットは予約する。
export function packTile(id: number, tile: Tile): number {
    return (id & 2047) | ((tile.x + 8) << 11) | ((tile.z + 8) << 15) | (tile.level << 19) | (tile.mask << 22);
}
export function unpackTile(value: number): Tile {
    return { x: ((value >>> 11) & 15) - 8, z: ((value >>> 15) & 15) - 8, level: (value >>> 19) & 7, mask: (value >>> 22) & 15 };
}

controls.txt

<details class="sandbox-panel" open><summary>描画</summary>
<label class="check"><input id="gpu-culling" type="checkbox" checked> フラスタムカリング</label>
<label class="check"><input id="gpu-freeze" type="checkbox"> カリング・配置を固定</label>
<label class="check"><input id="gpu-aerial" type="checkbox" checked> 大気遠近法</label>
</details>
<details class="sandbox-panel"><summary>カメラ</summary>
<label class="parameter"><span>移動速度</span><input id="gpu-speed" type="range" min="10" max="600" step="10" value="150"><output id="gpu-speed-value">150 m/s</output></label>
<label class="parameter"><span>垂直画角</span><input id="gpu-fov" type="range" min="30" max="110" step="1" value="60"><output id="gpu-fov-value">60°</output></label>
<label class="parameter"><span>露出 EV100</span><input id="gpu-ev100" type="range" min="-10" max="15" step="0.1" value="0"><output id="gpu-ev100-value">0 EV</output></label>
</details>
<details class="sandbox-panel"><summary>地形</summary>
<label class="parameter"><span>シード</span><input id="gpu-seed" type="number" min="0" max="4294967295" step="1" value="12343725"></label>
<label class="parameter"><span>標高振幅</span><input id="gpu-amplitude" type="range" min="100" max="2000" step="50" value="2000"><output id="gpu-amplitude-value">2000 m</output></label>
<label class="parameter"><span>山スケール</span><input id="gpu-scale" type="range" min="500" max="8000" step="100" value="3100"><output id="gpu-scale-value">3100 m</output></label>
<label class="parameter"><span>アルベド</span><select id="gpu-albedo-mode"><option value="height">標高に応じたグレー</option><option value="constant">固定値</option></select></label>
<label class="parameter"><span>アルベド R</span><input id="gpu-albedo-r" type="range" min="0" max="1" step="0.01" value="1" disabled><output id="gpu-albedo-r-value">1</output></label>
<label class="parameter"><span>アルベド G</span><input id="gpu-albedo-g" type="range" min="0" max="1" step="0.01" value="1" disabled><output id="gpu-albedo-g-value">1</output></label>
<label class="parameter"><span>アルベド B</span><input id="gpu-albedo-b" type="range" min="0" max="1" step="0.01" value="1" disabled><output id="gpu-albedo-b-value">1</output></label>
<button id="gpu-regenerate" type="button" disabled>地形を再生成</button>
<label class="parameter"><span>タイル密度</span><select id="gpu-density"><option value="4">4×4</option><option value="8">8×8</option><option value="16">16×16</option></select></label>
<label class="parameter"><span>カバー半径</span><select id="gpu-radius"><option value="250">250 m</option><option value="500">500 m</option><option value="1000">1 km</option><option value="2000">2 km</option><option value="4000">4 km</option><option value="8000">8 km</option><option value="16000" selected>16 km</option></select></label>
<label class="check"><input id="gpu-snap" type="checkbox" checked> セルスナップ</label>
</details>
<details class="sandbox-panel" open><summary>ライト・シャドウ</summary>
<label class="check"><input id="gpu-direct-light" type="checkbox" checked> 直接光</label>
<label class="parameter"><span>方位角</span><input id="gpu-light-azimuth" type="range" min="0" max="360" step="1" value="60"><output id="gpu-light-azimuth-value">60°</output></label>
<label class="parameter"><span>仰角</span><input id="gpu-light-elevation" type="range" min="1" max="85" step="1" value="30"><output id="gpu-light-elevation-value">30°</output></label>
<label class="check"><input id="gpu-shadows" type="checkbox" checked> シャドウ</label>
<label class="parameter"><span>投影半幅</span><select id="gpu-shadow-width"><option value="512">512 m</option><option value="1024">1 km</option><option value="2048" selected>2 km</option><option value="4096">4 km</option><option value="8192">8 km</option><option value="16384">16 km</option></select></label>
<label class="parameter"><span>深度バイアス</span><input id="gpu-depth-bias" type="range" min="0" max="0.001" step="0.00001" value="0.00005"><output id="gpu-depth-bias-value">0.00005</output></label>
<label class="parameter"><span>法線バイアス</span><input id="gpu-normal-bias" type="range" min="0" max="10" step="0.1" value="1"><output id="gpu-normal-bias-value">1 m</output></label>
<label class="parameter"><span>PCF</span><select id="gpu-pcf"><option value="0">1 tap</option><option value="1" selected>3×3</option><option value="2">5×5</option></select></label>
</details>
<details class="sandbox-panel"><summary>SkyLight</summary>
<label class="parameter"><span>SkyLight強度</span><input id="gpu-sky-intensity" type="range" min="0" max="2" step="0.05" value="1"><output id="gpu-sky-intensity-value">1</output></label>
</details>
<details class="sandbox-panel"><summary>診断</summary>
<label class="check"><input id="gpu-sky-map" type="checkbox"> Sky Octahedral Map</label>
<label class="check"><input id="gpu-normals" type="checkbox"> 法線表示</label>
<label class="check"><input id="gpu-wireframe" type="checkbox"> ワイヤーフレーム</label>
<label class="check"><input id="gpu-height-grid" type="checkbox"> HeightMapグリッド(16×16テクセル)</label>
<label class="check"><input id="gpu-height-cascades" type="checkbox"> Height Clipmap色分け</label>
<label class="check"><input id="gpu-tile-colors" type="checkbox"> タイル色分け</label>
<label class="check"><input id="gpu-shadow-mask" type="checkbox"> シャドウ可視率</label>
<label class="parameter"><span>可視リスト集約</span><select id="gpu-compaction"><option value="auto">自動</option><option value="workgroup">workgroup</option></select></label>
</details>

height-clipmap.ts

export const HEIGHT_SIZE = 1024;
export const MAX_HEIGHT_LEVELS = 7;
export const HEIGHT_LEVEL_BYTES = HEIGHT_SIZE * HEIGHT_SIZE * 2;
export const HEIGHT_JOB_BYTES = 48;
const JOB_STRIDE = 256;
export interface HeightOrigin {
    x: number;
    z: number;
}
export interface HeightRect {
    x: number;
    z: number;
    width: number;
    height: number;
}
export interface TerrainParameters {
    seed: number;
    amplitude: number;
    scale: number;
}
export interface HeightUpdateStats {
    levels: number;
    samples: number;
    rectangles: number;
}
// LOD0/1は同じ1m標高を使い、以後は段ごとに標高間隔を2倍にする。
// 各標高窓の半幅は対応するメッシュ段の半幅の2倍以上となり、補間・法線参照の余裕を持つ。
export function heightLevels(meshLevels: number): number {
    return Math.max(1, meshLevels - 1);
}
export function heightOrigin(x: number, z: number, level: number, size = HEIGHT_SIZE): HeightOrigin {
    const step = 2 ** level;
    // Xは2サンプルずつ動かし、FP16ペアの途中を別dispatchが書かないようにする。
    return { x: Math.floor(x / (2 * step)) * 2 - size / 2, z: Math.floor(z / step) - size / 2 };
}
// 前の窓との重複を保持し、露出した縦帯と、縦帯を除いた横帯だけを返す。
// 座標は世界の標高格子。物理バッファへのラップはComputeで行う。
export function updateRects(previous: HeightOrigin | undefined, next: HeightOrigin, size = HEIGHT_SIZE): HeightRect[] {
    if (!previous || Math.abs(next.x - previous.x) >= size || Math.abs(next.z - previous.z) >= size) {
        return [{ ...next, width: size, height: size }];
    }
    const dx = next.x - previous.x;
    const dz = next.z - previous.z;
    const result: HeightRect[] = [];
    if (dx !== 0) {
        result.push({ x: dx > 0 ? previous.x + size : next.x, z: next.z, width: Math.abs(dx), height: size });
    }
    if (dz !== 0) {
        result.push({ x: Math.max(previous.x, next.x), z: dz > 0 ? previous.z + size : next.z,
            width: size - Math.abs(dx), height: Math.abs(dz) });
    }
    return result;
}
export function terrainBound(parameters: TerrainParameters): number {
    // ノイズ値は0〜1、各帯域の振幅和は1未満。FP16丸めの余裕も含めて保守的に囲う。
    return parameters.amplitude * 0.75 + 2;
}
export class HeightClipmap {
    readonly buffer: GPUBuffer;
    readonly windows = new Int32Array(MAX_HEIGHT_LEVELS * 4);
    readonly bytes: number;
    private readonly jobs: GPUBuffer;
    private readonly group: GPUBindGroup;
    private readonly previous: Array<HeightOrigin | undefined>;
    private dirty = true;
    private disposed = false;
    private readonly device: GPUDevice;
    private readonly pipeline: GPUComputePipeline;
    readonly count: number;
    private parameters: TerrainParameters;
    constructor(device: GPUDevice, pipeline: GPUComputePipeline, count: number, parameters: TerrainParameters) {
        this.device = device;
        this.pipeline = pipeline;
        this.count = count;
        this.parameters = parameters;
        if (!Number.isInteger(count) || count < 1 || count > MAX_HEIGHT_LEVELS) {
            throw new Error('Height Clipmapの段数が範囲外です');
        }
        this.bytes = count * HEIGHT_LEVEL_BYTES;
        this.buffer = device.createBuffer({ label: 'FP16標高Clipmap', size: this.bytes,
            usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
        this.jobs = device.createBuffer({ label: '標高の差分生成条件', size: count * 2 * JOB_STRIDE,
            usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
        this.group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
                { binding: 0, resource: { buffer: this.jobs, size: HEIGHT_JOB_BYTES } },
                { binding: 1, resource: { buffer: this.buffer } }
            ] });
        this.previous = Array.from({ length: count });
    }
    regenerate(parameters: TerrainParameters): void {
        this.parameters = parameters;
        this.dirty = true;
    }
    encode(encoder: GPUCommandEncoder, x: number, z: number): HeightUpdateStats {
        const jobs: Array<{
            level: number;
            rect: HeightRect;
        }> = [];
        const stats: HeightUpdateStats = { levels: 0, samples: 0, rectangles: 0 };
        for (let level = 0; level < this.count; level++) {
            const next = heightOrigin(x, z, level);
            const rects = updateRects(this.dirty ? undefined : this.previous[level], next);
            this.windows.set([next.x, next.z, 0, 0], level * 4);
            if (rects.length > 0) {
                stats.levels++;
            }
            for (const rect of rects) {
                jobs.push({ level, rect });
                stats.samples += rect.width * rect.height;
            }
            this.previous[level] = next;
        }
        this.dirty = false;
        stats.rectangles = jobs.length;
        if (jobs.length === 0) {
            return stats;
        }
        // 各矩形のUniformを先にまとめて転送し、dispatchごとは動的オフセットだけを切り替える。
        const data = new ArrayBuffer(jobs.length * JOB_STRIDE);
        const view = new DataView(data);
        for (let i = 0; i < jobs.length; i++) {
            const { level, rect } = jobs[i];
            const offset = i * JOB_STRIDE;
            view.setInt32(offset, rect.x, true);
            view.setInt32(offset + 4, rect.z, true);
            view.setInt32(offset + 8, rect.width, true);
            view.setInt32(offset + 12, rect.height, true);
            view.setUint32(offset + 16, level, true);
            view.setUint32(offset + 20, this.parameters.seed, true);
            view.setFloat32(offset + 32, 2 ** level, true);
            view.setFloat32(offset + 36, this.parameters.amplitude, true);
            view.setFloat32(offset + 40, this.parameters.scale, true);
        }
        this.device.queue.writeBuffer(this.jobs, 0, data);
        const pass = encoder.beginComputePass({ label: '標高Clipmapの差分生成' });
        pass.setPipeline(this.pipeline);
        for (let i = 0; i < jobs.length; i++) {
            const rect = jobs[i].rect;
            pass.setBindGroup(0, this.group, [i * JOB_STRIDE]);
            // 1 invocationが隣接する2サンプルを所有し、1個のu32へ書き込む。
            pass.dispatchWorkgroups(Math.ceil(rect.width / 16), Math.ceil(rect.height / 8));
        }
        pass.end();
        return stats;
    }
    dispose(): void {
        if (this.disposed) {
            return;
        }
        this.disposed = true;
        this.buffer.destroy();
        this.jobs.destroy();
    }
}

main.ts

import { cameraFrame } from './camera-frame.js';
import { SkyDebug } from './sky-debug.js';
import { PostProcess } from './post-process.js';
import { SkyEnvironment } from './sky-environment.js';
import { lightDirection, shadowMatrix, frustumPlanes, SHADOW_RESOLUTION } from './render-math.js';
import { createOpaquePipelines, encodeOpaquePasses } from './render-passes.js';
import type { OpaqueRenderable } from './render-passes.js';
import { createTileIndices, createLayout, cascadeParameters, MAX_CANDIDATES } from './clipmap-grid.js';
import { HeightClipmap, heightLevels, terrainBound, HEIGHT_SIZE, HEIGHT_JOB_BYTES } from './height-clipmap.js';
import type { TerrainParameters } from './height-clipmap.js';
type Vec3 = [
    number,
    number,
    number
];
interface Demo {
    draw(): void;
    dispose(): void;
    regenerate(parameters: TerrainParameters): void;
}
function element<T extends Element>(selector: string, type: {
    new (): T;
}): T {
    const value = document.querySelector(selector);
    if (!(value instanceof type)) {
        throw new Error(`要素が見つかりません: ${selector}`);
    }
    return value;
}
const start = element('#gpu-start', HTMLButtonElement);
const resetCamera = element('#gpu-camera-reset', HTMLButtonElement);
const canvas = element('#gpu-canvas', HTMLCanvasElement);
// GPUの実行状態とは独立して、実寸のツールバー+ビュー高へ設定パネルを合わせる。
// メトリクスや設定項目の展開では上限を増やさず、幅変更とズームにだけ追従する。
const inspector = element('.sandbox-inspector', HTMLElement);
const toolbar = element('.sandbox-toolbar', HTMLElement);
const workspace = element('.sandbox-workspace', HTMLElement);
inspector.tabIndex = 0;
const updateInspectorHeight = (): void => {
    const height = canvas.getBoundingClientRect().bottom - toolbar.getBoundingClientRect().top;
    workspace.style.setProperty('--inspector-height', `${Math.ceil(height)}px`);
};
const inspectorObserver = new ResizeObserver(updateInspectorHeight);
const observeInspectorLayout = (): void => {
    inspectorObserver.observe(canvas);
    inspectorObserver.observe(toolbar);
    updateInspectorHeight();
};
observeInspectorLayout();
window.addEventListener('pagehide', () => inspectorObserver.disconnect());
window.addEventListener('pageshow', observeInspectorLayout);
const status = element('#gpu-status', HTMLParagraphElement);
const info = element('#gpu-info', HTMLDListElement);
const snapGrid = element('#gpu-snap', HTMLInputElement);
const showNormals = element('#gpu-normals', HTMLInputElement);
const wireframe = element('#gpu-wireframe', HTMLInputElement);
const tileColors = element('#gpu-tile-colors', HTMLInputElement);
const heightCascades = element('#gpu-height-cascades', HTMLInputElement);
const heightGrid = element('#gpu-height-grid', HTMLInputElement);
const culling = element('#gpu-culling', HTMLInputElement);
const freeze = element('#gpu-freeze', HTMLInputElement);
const density = element('#gpu-density', HTMLSelectElement);
const radius = element('#gpu-radius', HTMLSelectElement);
const seedInput = element('#gpu-seed', HTMLInputElement);
const amplitudeInput = element('#gpu-amplitude', HTMLInputElement);
const scaleInput = element('#gpu-scale', HTMLInputElement);
const regenerate = element('#gpu-regenerate', HTMLButtonElement);
const amplitudeValue = element('#gpu-amplitude-value', HTMLOutputElement);
const scaleValue = element('#gpu-scale-value', HTMLOutputElement);
const shadows = element('#gpu-shadows', HTMLInputElement);
const ev100 = element('#gpu-ev100', HTMLInputElement);
const verticalFov = element('#gpu-fov', HTMLInputElement);
const showSkyMap = element('#gpu-sky-map', HTMLInputElement);
const aerial = element('#gpu-aerial', HTMLInputElement);
const skyIntensity = element('#gpu-sky-intensity', HTMLInputElement);
const albedoMode = element('#gpu-albedo-mode', HTMLSelectElement);
const albedoInputs = ['r', 'g', 'b'].map(channel => element(`#gpu-albedo-${channel}`, HTMLInputElement));
const directLight = element('#gpu-direct-light', HTMLInputElement);
const shadowMask = element('#gpu-shadow-mask', HTMLInputElement);
const azimuth = element('#gpu-light-azimuth', HTMLInputElement);
const elevation = element('#gpu-light-elevation', HTMLInputElement);
const shadowWidth = element('#gpu-shadow-width', HTMLSelectElement);
const depthBias = element('#gpu-depth-bias', HTMLInputElement);
const normalBias = element('#gpu-normal-bias', HTMLInputElement);
const pcf = element('#gpu-pcf', HTMLSelectElement);
const compaction = element('#gpu-compaction', HTMLSelectElement);
const layoutControls = [snapGrid, culling, density, radius, compaction, seedInput, amplitudeInput, scaleInput, regenerate, shadows, azimuth, elevation, shadowWidth, depthBias, normalBias, pcf];
const speed = element('#gpu-speed', HTMLInputElement);
const speedValue = element('#gpu-speed-value', HTMLOutputElement);
const cameraValue = element('#gpu-camera-value', HTMLOutputElement);
const initialCamera = { yaw: 0.55, pitch: 0 };
function defaultCamera(): {
    yaw: number;
    pitch: number;
    position: Vec3;
} {
    return {
        ...initialCamera,
        position: [0, 200, 0]
    };
}
let camera = defaultCamera(), generation = 0, busy = false, frame = 0, lastTime = 0;
const keys = new Set<string>();
const movementKeys = new Set(['KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyQ', 'KeyE']);
const lookKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
canvas.tabIndex = 0;
canvas.setAttribute('aria-label', '地形ビュー。WASDで移動、Q/Eで下降・上昇、矢印キーまたはドラッグで視点回転');
let demo: Demo | undefined, controller: AbortController | undefined;
const pointers = new Map<number, {
    x: number;
    y: number;
}>();
function normalize(v: Vec3): Vec3 { const length = Math.hypot(...v); return [v[0] / length, v[1] / length, v[2] / length]; }
function cross(a: Vec3, b: Vec3): Vec3 { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function dot(a: Vec3, b: Vec3): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function viewProjection(fov: number): Float32Array<ArrayBuffer> {
    const z: Vec3 = [Math.sin(camera.yaw) * Math.cos(camera.pitch), Math.sin(camera.pitch), Math.cos(camera.yaw) * Math.cos(camera.pitch)];
    const x = normalize(cross([0, 1, 0], z)), y = cross(z, x);
    const eye = camera.position;
    const f = 1 / Math.tan(fov / 2), sx = f / (canvas.width / canvas.height);
    // 右手系のビュー。NDCのZ=near/距離とし、near=1、far=無限遠のReversed Zを使う。
    return new Float32Array([x[0] * sx, y[0] * f, 0, -z[0], x[1] * sx, y[1] * f, 0, -z[1],
        x[2] * sx, y[2] * f, 0, -z[2], -dot(x, eye) * sx, -dot(y, eye) * f, 1, dot(z, eye)]);
}
async function bytes(name: string, signal: AbortSignal): Promise<ArrayBuffer> {
    const response = await fetch(new URL(name, import.meta.url), { signal, cache: 'no-cache' });
    if (!response.ok) {
        throw new Error(`${name}: HTTP ${response.status}`);
    }
    return response.arrayBuffer();
}
function utf8(data: ArrayBuffer, name: string): string {
    const b = new Uint8Array(data);
    if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) {
        throw new Error(`${name}: UTF-8 BOMは使用できません`);
    }
    try {
        return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data);
    }
    catch {
        throw new Error(`${name}: 不正なUTF-8です`);
    }
}
function terrainParameters(): TerrainParameters {
    const seed = Number(seedInput.value);
    const amplitude = Number(amplitudeInput.value);
    const scale = Number(scaleInput.value);
    if (seedInput.value.trim() === '' || !Number.isInteger(seed) || seed < 0 || seed > 4294967295
        || !Number.isFinite(amplitude) || amplitude < 100 || amplitude > 2000
        || !Number.isFinite(scale) || scale < 500 || scale > 8000) {
        throw new Error('地形パラメータが範囲外です。シードはuint32の整数で指定してください');
    }
    return { seed, amplitude, scale };
}
function detail(parent: HTMLDListElement, label: string, value: string): HTMLElement {
    const dt = document.createElement('dt'), dd = document.createElement('dd');
    dt.textContent = label;
    dd.textContent = value;
    parent.append(dt, dd);
    return dd;
}
// ローカル地形のY=0を海抜2,000mに置き、負の標高も地球表面より上に収める。
const ATMOSPHERE_REFERENCE_ALTITUDE = 2000;

// 動的な数値を先に見せ、構成・資源は必要なときだけ展開できるように分離する。
function metricGroups(): { activity: HTMLDListElement; configuration: HTMLDListElement } {
    const heading = document.createElement('dt');
    heading.textContent = '実行状況';
    heading.style.fontWeight = 'bold';
    const activityBody = document.createElement('dd');
    const activity = document.createElement('dl');
    activityBody.append(activity);
    const configHeading = document.createElement('dt');
    configHeading.textContent = '構成情報';
    configHeading.style.fontWeight = 'bold';
    const configBody = document.createElement('dd');
    const disclosure = document.createElement('details');
    const summary = document.createElement('summary');
    summary.textContent = '設定とGPUリソースを表示';
    summary.style.cursor = 'pointer';
    const configuration = document.createElement('dl');
    disclosure.append(summary, configuration);
    configBody.append(disclosure);
    info.replaceChildren(heading, activityBody, configHeading, configBody);
    return { activity, configuration };
}
function updateDetail(node: HTMLElement, value: string): void {
    // カメラ移動中も、変わっていない構成情報のDOMは書き換えない。
    if (node.textContent !== value) { node.textContent = value; }
}
function clearPointers(): void {
    for (const id of [...pointers.keys()]) {
        pointers.delete(id);
        if (canvas.hasPointerCapture(id)) {
            canvas.releasePointerCapture(id);
        }
    }
    canvas.style.cursor = demo ? 'grab' : 'auto';
}
function release(): void {
    cancelAnimationFrame(frame);
    frame = 0;
    keys.clear();
    lastTime = 0;
    controller?.abort();
    controller = undefined;
    clearPointers();
    demo?.dispose();
    demo = undefined;
    freeze.checked = false;
    layoutControls.forEach(c => { c.disabled = false; });
    generation++;
    busy = false;
    resetCamera.disabled = true;
    regenerate.disabled = true;
    canvas.style.touchAction = 'auto';
    canvas.style.cursor = 'auto';
    start.disabled = false;
    start.textContent = '実行';
}
function fail(error: unknown): void { release(); status.textContent = `実行できませんでした: ${error instanceof Error ? error.message : String(error)}`; }
function updateCameraReadout(): void {
    cameraValue.value = `X ${camera.position[0].toFixed(1)} / Y ${camera.position[1].toFixed(1)} / Z ${camera.position[2].toFixed(1)} m`;
}
function moveCamera(forward: number, right: number, up: number, distance: number): void {
    const cp = Math.cos(camera.pitch), sp = Math.sin(camera.pitch), sy = Math.sin(camera.yaw), cy = Math.cos(camera.yaw);
    camera.position[0] += (-sy * cp * forward + cy * right) * distance;
    camera.position[1] += (-sp * forward + up) * distance;
    camera.position[2] += (-cy * cp * forward - sy * right) * distance;
}
function scheduleDraw(): void {
    if (!demo || frame || document.hidden) {
        return;
    }
    frame = requestAnimationFrame(tick);
}
function tick(time: number): void {
    frame = 0;
    if (!demo || document.hidden) {
        return;
    }
    const dt = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0;
    lastTime = time;
    const pressed = (code: string): number => Number(keys.has(code));
    const forward = pressed('KeyW') - pressed('KeyS'), right = pressed('KeyD') - pressed('KeyA'), up = pressed('KeyE') - pressed('KeyQ');
    const length = Math.hypot(forward, right, up);
    if (length) {
        moveCamera(forward / length, right / length, up / length, Number(speed.value) * (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 4 : 1) * dt);
    }
    camera.yaw += (pressed('ArrowLeft') - pressed('ArrowRight')) * dt;
    camera.pitch = Math.max(-1.54, Math.min(1.54, camera.pitch + (pressed('ArrowDown') - pressed('ArrowUp')) * dt));
    try {
        demo.draw();
        updateCameraReadout();
    }
    catch (error) {
        fail(error);
        return;
    }
    if ([...keys].some(code => movementKeys.has(code) || lookKeys.has(code))) {
        scheduleDraw();
    }
    else {
        lastTime = 0;
    }
}
async function initialize(token: number, signal: AbortSignal): Promise<Demo> {
    const check = (): void => {
        if (token !== generation || signal.aborted) {
            throw new Error('初期化を中断しました');
        }
    };
    if (!window.isSecureContext || !navigator.gpu) {
        throw new Error('WebGPU対応ブラウザでHTTPSまたはlocalhostから開いてください');
    }
    let parameters = terrainParameters();
    status.textContent = 'GPUとプロシージャル地形を初期化しています';
    const adapter = await navigator.gpu.requestAdapter();
    check();
    if (!adapter) {
        throw new Error('GPUアダプターを取得できませんでした');
    }
    const hasSubgroups = adapter.features.has('subgroups');
    const device = await adapter.requestDevice({ requiredFeatures: hasSubgroups ? ['subgroups'] : [] });
    let context: GPUCanvasContext | null = null;
    let depth: GPUTexture | undefined;
    let hdrColor: GPUTexture | undefined;
    let shadowDepth: GPUTexture | undefined;
    let cache: HeightClipmap | undefined;
    let skyEnvironment: SkyEnvironment | undefined;
    let disposed = false;
    const buffers: GPUBuffer[] = [];
    const retired = new Set<HeightClipmap>();
    function dispose(): void {
        if (disposed) {
            return;
        }
        disposed = true;
        if (token === generation) {
            context?.unconfigure();
        }
        cache?.dispose();
        skyEnvironment?.dispose();
        retired.forEach(old => old.dispose());
        buffers.forEach(buffer => buffer.destroy());
        depth?.destroy();
        hdrColor?.destroy();
        shadowDepth?.destroy();
        device.destroy();
    }
    try {
        check();
        void device.lost.then(({ reason, message }) => {
            if (!disposed && token === generation && reason !== 'destroyed') {
                fail(new Error(`GPUデバイスを失いました: ${message || reason}`));
            }
        });
        device.addEventListener('uncapturederror', event => {
            if (!disposed && token === generation) {
                fail(event.error);
            }
        });
        context = canvas.getContext('webgpu');
        if (!context) {
            throw new Error('WebGPU Canvasを取得できませんでした');
        }
        const activeContext = context;
        const format = navigator.gpu.getPreferredCanvasFormat();
        activeContext.configure({ device, format, alphaMode: 'opaque' });
        const common = utf8(await bytes('tile-common.wgsl', signal), 'tile-common.wgsl');
        async function shader(name: string, library = '', directive = '', includeScene = true): Promise<GPUShaderModule> {
            const code = directive + (includeScene ? common : '') + '\n' + library + '\n' + utf8(await bytes(name, signal), name);
            check();
            device.pushErrorScope('validation');
            const module = device.createShaderModule({ code, label: name });
            const compilation = await module.getCompilationInfo();
            const error = await device.popErrorScope();
            check();
            const messages = compilation.messages.filter(message => message.type === 'error');
            if (error || messages.length > 0) {
                throw new Error(`${name}: ${messages.map(message => `${message.lineNum}: ${message.message}`).join('; ') || error?.message}`);
            }
            return module;
        }
        const cameraLibrary = utf8(await bytes('camera.wgsl', signal), 'camera.wgsl');
        const geometryLibrary = utf8(await bytes('terrain-geometry.wgsl', signal), 'terrain-geometry.wgsl');
        const atmosphereLibrary = utf8(await bytes('atmosphere.wgsl', signal), 'atmosphere.wgsl');
        const skyLibrary = atmosphereLibrary + utf8(await bytes('sky-common.wgsl', signal), 'sky-common.wgsl');
        const octahedralLibrary = utf8(await bytes('sky-octahedral.wgsl', signal), 'sky-octahedral.wgsl');
        const shLibrary = utf8(await bytes('sky-sh.wgsl', signal), 'sky-sh.wgsl');
        const renderShader = await shader('terrain-forward.wgsl', geometryLibrary + shLibrary + atmosphereLibrary + cameraLibrary);
        const skyGenerate = await shader('sky-generate.wgsl', skyLibrary + octahedralLibrary, '', false);
        const skyProject = await shader('sky-project.wgsl', octahedralLibrary + shLibrary, '', false);
        const skyReduce = await shader('sky-reduce.wgsl', shLibrary, '', false);
        const skyRender = await shader('sky-render.wgsl', skyLibrary + cameraLibrary, '', false);
        const skyDebugShader = await shader('sky-debug.wgsl', cameraLibrary, '', false);
        const postShader = await shader('post-process.wgsl', '', '', false);
        const depthShader = await shader('terrain-zonly.wgsl', geometryLibrary);
        const heightShader = await shader('generate-height.wgsl', '', '', false);
        const cullLibrary = utf8(await bytes('cull.wgsl', signal), 'cull.wgsl');
        const workgroupShader = await shader('compact-workgroup.wgsl', cullLibrary);
        const subgroupShader = hasSubgroups ? await shader('compact-subgroup.wgsl', cullLibrary, 'enable subgroups;\n') : undefined;
        const grid = createTileIndices();
        device.pushErrorScope('validation');
        function buffer(size: number, usage: GPUBufferUsageFlags, mappedAtCreation = false): GPUBuffer {
            const result = device.createBuffer({ size, usage, mappedAtCreation });
            buffers.push(result);
            return result;
        }
        function indexBuffer(data: Uint16Array<ArrayBuffer>): GPUBuffer {
            const result = buffer(data.byteLength, GPUBufferUsage.INDEX, true);
            new Uint16Array(result.getMappedRange()).set(data);
            result.unmap();
            return result;
        }
        const indices = indexBuffer(grid.surface);
        const wireIndices = indexBuffer(grid.wire);
        // メインとシャドウは独立したUniform・可視リスト・Indirect引数を所有する。
        // 候補タイルと標高は共有するが、メイン不可視のキャスターも光源視点で残す。
        function createView(label: string) {
            const uniform = buffer(480, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
            const visible = buffer(MAX_CANDIDATES * 4, GPUBufferUsage.STORAGE);
            const indirect = buffer(20, GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC);
            uniform.label = label + ' Scene';
            visible.label = label + ' 可視タイル';
            indirect.label = label + ' Indirect';
            device.queue.writeBuffer(indirect, 0, new Uint32Array([grid.surface.length, 0, 0, 0, 0]));
            return { uniform, visible, indirect, settings: new Float32Array(120) };
        }
        const mainView = createView('Main');
        const shadowView = createView('Shadow');
        const wireIndirect = buffer(20, GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST);
        device.queue.writeBuffer(wireIndirect, 0, new Uint32Array([grid.wire.length, 0, 0, 0, 0]));
        const readback = buffer(40, GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST);
        // 標高生成だけは矩形ごとのUniformを動的オフセットで参照する。
        // 生成先は標高のFP16ペアだけで、元DEMや一時的なFloat32標高面を持たない。
        const heightLayout = device.createBindGroupLayout({ entries: [
                { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: HEIGHT_JOB_BYTES } },
                { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }
            ] });
        const heightPipeline = await device.createComputePipelineAsync({
            layout: device.createPipelineLayout({ bindGroupLayouts: [heightLayout] }),
            compute: { module: heightShader, entryPoint: 'generateMain' }
        });
        const computeLayout = device.createBindGroupLayout({ entries: [
                { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
                { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
                { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }
            ] });
        const computePipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [computeLayout] });
        const workgroupCompute = await device.createComputePipelineAsync({ layout: computePipelineLayout, compute: { module: workgroupShader, entryPoint: 'cullMain' } });
        const subgroupCompute = subgroupShader ? await device.createComputePipelineAsync({ layout: computePipelineLayout, compute: { module: subgroupShader, entryPoint: 'cullMain' } }) : undefined;
        function computeGroup(view: ReturnType<typeof createView>): GPUBindGroup {
            return device.createBindGroup({ layout: computeLayout, entries: [
                { binding: 0, resource: { buffer: view.uniform } },
                { binding: 1, resource: { buffer: view.visible } },
                { binding: 2, resource: { buffer: view.indirect } }
            ] });
        }
        const mainComputeGroup = computeGroup(mainView);
        const shadowComputeGroup = computeGroup(shadowView);
        const groupLayout = device.createBindGroupLayout({ entries: [
                { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
                { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
                { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }
            ] });
        const cameraBuffer = buffer(16, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
        const environment = await SkyEnvironment.create(device, { generate: skyGenerate, project: skyProject, reduce: skyReduce, render: skyRender }, 'rgba16float', cameraBuffer);
        skyEnvironment = environment;
        check();
        const skyDebug = await SkyDebug.create(device, skyDebugShader, environment.map.createView(), cameraBuffer);
        check();
        const lightingBuffer = buffer(160, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
        shadowDepth = device.createTexture({ label: 'Directional Shadow Map', size: [SHADOW_RESOLUTION, SHADOW_RESOLUTION],
            format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
        const shadowDepthView = shadowDepth.createView();
        const lightingLayout = device.createBindGroupLayout({ entries: [
            { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
            { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'depth' } },
            { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'comparison' } },
            { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
            { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }
        ] });
        const lightingGroup = device.createBindGroup({ layout: lightingLayout, entries: [
            { binding: 0, resource: { buffer: lightingBuffer } },
            { binding: 1, resource: shadowDepthView },
            { binding: 2, resource: device.createSampler({ compare: 'greater-equal', minFilter: 'linear', magFilter: 'linear' }) },
            { binding: 3, resource: { buffer: environment.coefficients } },
            { binding: 4, resource: { buffer: cameraBuffer } }
        ] });
        const pipelines = await createOpaquePipelines(device, groupLayout, lightingLayout, depthShader, renderShader, 'rgba16float');
        check();
        let layout = createLayout(Number(density.value), Number(radius.value));
        let origins = cascadeParameters(camera.position[0], camera.position[2], layout, snapGrid.checked);
        let mainGroup: GPUBindGroup;
        let shadowGroup: GPUBindGroup;
        function replaceCache(count: number): HeightClipmap {
            const previous = cache;
            const next = new HeightClipmap(device, heightPipeline, count, parameters);
            cache = next;
            function geometryGroup(view: ReturnType<typeof createView>): GPUBindGroup {
                return device.createBindGroup({ layout: groupLayout, entries: [
                    { binding: 0, resource: { buffer: view.uniform } },
                    { binding: 1, resource: { buffer: next.buffer } },
                    { binding: 2, resource: { buffer: view.visible } }
                ] });
            }
            mainGroup = geometryGroup(mainView);
            shadowGroup = geometryGroup(shadowView);
            if (previous) {
                // UIで段数を変えたときだけ再確保する。古い描画の完了後に旧資源を破棄する。
                retired.add(previous);
                void device.queue.onSubmittedWorkDone().then(() => {
                    previous.dispose();
                    retired.delete(previous);
                }).catch(() => { previous.dispose(); retired.delete(previous); });
            }
            return next;
        }
        replaceCache(heightLevels(layout.levels));
        depth = device.createTexture({ size: [canvas.width, canvas.height], format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT });
        const depthView = depth.createView();
        // Canvasと同サイズのシーンリニアHDR。全シェーディングはここへ書く。
        hdrColor = device.createTexture({ label: 'Scene HDR Linear', size: [canvas.width, canvas.height],
            format: 'rgba16float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
        const hdrView = hdrColor.createView();
        const display = await PostProcess.create(device, postShader, hdrView, format);
        check();
        // 将来のモデルも同じOpaqueRenderable契約へ追加する。位置用・色用のシェーダーを分ける。
        const terrain: OpaqueRenderable = {
            drawDepth(pass, view) {
                pass.setPipeline(pipelines.zOnly);
                pass.setBindGroup(0, view === 'main' ? mainGroup : shadowGroup);
                pass.setIndexBuffer(indices, 'uint16');
                pass.drawIndexedIndirect(view === 'main' ? mainView.indirect : shadowView.indirect, 0);
            },
            drawForward(pass) {
                pass.setPipeline(pipelines.forward);
                pass.setBindGroup(0, mainGroup);
                pass.setBindGroup(1, lightingGroup);
                pass.setIndexBuffer(indices, 'uint16');
                pass.drawIndexedIndirect(mainView.indirect, 0);
            },
            drawDebug(pass) {
                pass.setPipeline(pipelines.wire);
                pass.setBindGroup(1, lightingGroup);
                pass.setBindGroup(0, mainGroup);
                pass.setIndexBuffer(wireIndices, 'uint16');
                pass.drawIndexedIndirect(wireIndirect, 0);
            }
        };
        const renderables: readonly OpaqueRenderable[] = [terrain];
        let lightMatrix = new Float32Array(16);
        let light = lightDirection(Number(azimuth.value), Number(elevation.value));
        let previousPreExposure = 1;
        let updates = 0;
        let pending = false;
        const { activity, configuration } = metricGroups();
        const heightInfo = detail(configuration, 'Height Clipmap', '');
        const heightUpdateInfo = detail(activity, '標高生成', '');
        const skyUpdateInfo = detail(activity, '天球・SH更新', '');
        const generationInfo = detail(configuration, '地形', '');
        detail(configuration, '共有タイル', `${grid.vertexCount.toLocaleString()}頂点ID / ${(grid.surface.length / 3).toLocaleString()}三角形 / VB 0 B`);
        detail(configuration, 'HDRカラー', `rgba16float / ${canvas.width}×${canvas.height} / ${(canvas.width * canvas.height * 8 / 1048576).toFixed(2)} MiB / PostProcessPassで表示変換`);
        detail(configuration, 'タイル共有IB', `uint16 / タイル描画 ${indices.size.toLocaleString()} B + ワイヤーフレーム ${wireIndices.size.toLocaleString()} B`);
        detail(configuration, 'GPU描画データ', '可視タイル 6,400 B×2 / Indirect 20 B×3 / Scene 480 B×2 / Lighting 160 B / Camera 16 B / 表示読戻し40 B');
        detail(configuration, '天球リソース', 'Octahedral 256² rgba16float 512 KiB / SH局所和144 KiB / 9係数144 B / 生成・表示Uniform計80 B');
        detail(configuration, '描画Pass', 'ZPrePass → ShadowPass → ForwardShadingPass(HDR・Equal)→ PostProcessPass(ACES Filmic・sRGB)');
        const shadowInfo = detail(configuration, 'Shadow Map', '');
        const skyInfo = detail(configuration, 'SkyLight Diffuse', '');
        const compactInfo = detail(configuration, '可視リスト集約', '');
        const layoutInfo = detail(configuration, 'メッシュLOD', '');
        const mainCountInfo = detail(activity, 'Main 可視タイル', 'GPU結果を取得中');
        const shadowCountInfo = detail(activity, 'Shadow 可視タイル', 'GPU結果を取得中');
        const updateInfo = detail(activity, '配置・カリング更新', '');
        detail(configuration, 'GPU', adapter.info.description || adapter.info.device || 'ブラウザから機種名は提供されていません');
        function draw(): void {
            if (disposed) {
                return;
            }
            const frameCamera = cameraFrame(Number(ev100.value), Number(verticalFov.value), previousPreExposure);
            device.queue.writeBuffer(cameraBuffer, 0, frameCamera.uniform);
            const matrix = viewProjection(frameCamera.verticalFov);
            const live = !freeze.checked || updates === 0;
            const encoder = device.createCommandEncoder({ label: 'RenderingSandbox Frame' });
            if (live) {
                layout = createLayout(Number(density.value), Number(radius.value));
                origins = cascadeParameters(camera.position[0], camera.position[2], layout, snapGrid.checked);
                const needed = heightLevels(layout.levels);
                if (!cache || cache.count !== needed) { replaceCache(needed); }
                light = lightDirection(Number(azimuth.value), Number(elevation.value));
                // 光源の深度範囲には現在の候補地形全体を保守的に含める。
                // XYの投影範囲は独立して狭く取り、画面外のキャスターもこの範囲で判定する。
                lightMatrix = shadowMatrix([camera.position[0], 0, camera.position[2]], light,
                    Number(shadowWidth.value), Math.SQRT2 * (layout.radius + 4 * layout.steps[layout.levels - 1]) + terrainBound(parameters) + 16,
                    SHADOW_RESOLUTION);
                mainView.settings.set(frustumPlanes(matrix), 16);
                shadowView.settings.set(frustumPlanes(lightMatrix), 16);
            }
            const skyChanged = environment.encode(encoder, light, camera.yaw, camera.pitch, canvas.width / canvas.height, Math.max(camera.position[1] + ATMOSPHERE_REFERENCE_ALTITUDE, 2), frameCamera.verticalFov);
            updateDetail(skyUpdateInfo, `#${environment.revision} / ${skyChanged ? 'Octahedral生成・SH投影' : '保持(方位・水平移動・露出の変更では再投影なし)'}`);
            const activeCache = cache;
            if (!activeCache) {
                throw new Error('Height Clipmapが初期化されていません');
            }
            if (live) {
                const stats = activeCache.encode(encoder, camera.position[0], camera.position[2]);
                updateDetail(heightUpdateInfo, `${stats.levels}段 / ${stats.samples.toLocaleString()}サンプル / ${stats.rectangles}矩形(前回の描画で更新)`);
            }
            const bound = terrainBound(parameters);
            function uploadView(view: ReturnType<typeof createView>, projection: Float32Array, height: HeightClipmap): void {
                const settings = view.settings;
                settings.set(projection);
                settings.set([...light, 0], 40);
                settings.set([-bound, 1 / (2 * bound), Number(showNormals.checked), Number(tileColors.checked)], 44);
                settings.set([HEIGHT_SIZE, height.count, 1, Number(heightCascades.checked)], 48);
                settings.set([0, -bound, bound, Number(heightGrid.checked)], 52);
                new Uint32Array(settings.buffer).set([layout.density, layout.levels, layout.count, Number(culling.checked)], 56);
                settings.set(origins, 60);
                new Int32Array(settings.buffer).set(height.windows, 92);
                device.queue.writeBuffer(view.uniform, 0, settings);
            }
            // 2つの別バッファへ転送する。同じUniformを書き換えて2 dispatchを発行しない。
            uploadView(mainView, matrix, activeCache);
            uploadView(shadowView, lightMatrix, activeCache);
            const lighting = new Float32Array(40);
            lighting.set(lightMatrix);
            lighting.set([...light, directLight.checked ? 20.0 : 0.0], 16);
            lighting.set([Number(shadows.checked), Number(depthBias.value), Number(normalBias.value), 1 / SHADOW_RESOLUTION], 20);
            lighting.set([Number(pcf.value), Number(shadowMask.checked), 0, 0], 24);
            // 材質変更は照明用Uniformだけに反映し、地形と天空の再生成は行わない。
            lighting.set([...albedoInputs.map(input => Number(input.value)), Number(skyIntensity.value)], 28);
            lighting.set([...camera.position, 0], 32);
            lighting.set([Number(aerial.checked), ATMOSPHERE_REFERENCE_ALTITUDE, Number(albedoMode.value === 'constant'), 0], 36);
            device.queue.writeBuffer(lightingBuffer, 0, lighting);
            if (live) {
                const compute = compaction.value === 'auto' && subgroupCompute ? subgroupCompute : workgroupCompute;
                updateDetail(compactInfo, compute === subgroupCompute ? 'subgroup集約 / 非空subgroupごとに1 AtomicAdd' : 'workgroup共有メモリscan / 非空workgroupごとに1 AtomicAdd');
                encoder.clearBuffer(mainView.indirect, 4, 4);
                encoder.clearBuffer(shadowView.indirect, 4, 4);
                const pass = encoder.beginComputePass({ label: 'Main/Shadow フラスタムカリング' });
                pass.setPipeline(compute);
                pass.setBindGroup(0, mainComputeGroup);
                pass.dispatchWorkgroups(Math.ceil(layout.count / 64));
                if (shadows.checked) {
                    pass.setBindGroup(0, shadowComputeGroup);
                    pass.dispatchWorkgroups(Math.ceil(layout.count / 64));
                }
                pass.end();
                encoder.copyBufferToBuffer(mainView.indirect, 4, wireIndirect, 4, 4);
                updates++;
            }
            encodeOpaquePasses(encoder, hdrView, depthView, shadowDepthView,
                renderables, shadows.checked, wireframe.checked, environment);
            if (showSkyMap.checked) { skyDebug.encode(encoder, hdrView, canvas.width, canvas.height); }
            display.encode(encoder, activeContext.getCurrentTexture().createView());
            // 状態表示だけを非同期に読む。標高・可視数のCPU取得を待って描画する経路はない。
            const read = !pending;
            const revision = updates;
            const candidates = layout.count;
            if (read) {
                pending = true;
                encoder.copyBufferToBuffer(mainView.indirect, 0, readback, 0, 20);
                encoder.copyBufferToBuffer(shadowView.indirect, 0, readback, 20, 20);
            }
            device.queue.submit([encoder.finish()]);
            previousPreExposure = frameCamera.preExposure;
            if (read) {
                void readback.mapAsync(GPUMapMode.READ).then(() => {
                    const values = new Uint32Array(readback.getMappedRange());
                    const count = values[1];
                    const shadowCount = values[6];
                    readback.unmap();
                    pending = false;
                    if (!disposed && revision === updates) {
                        updateDetail(mainCountInfo, `${count} / 候補 ${candidates} タイル`);
                        updateDetail(shadowCountInfo, shadows.checked ? `${shadowCount} / 候補 ${candidates} タイル` : 'OFF(描画なし)');
                    }
                    else if (!disposed) {
                        scheduleDraw();
                    }
                }).catch(error => { pending = false; if (!disposed && token === generation) {
                    fail(error);
                } });
            }
            updateDetail(heightInfo, `1024×1024 × ${activeCache.count}段 / FP16 ${(activeCache.bytes / 1048576).toFixed(0)} MiB / 間隔 ${Array.from({ length: activeCache.count }, (_, l) => 2 ** l).join('・')} m`);
            updateDetail(generationInfo, `seed ${parameters.seed} / 振幅 ${parameters.amplitude} m / 山スケール ${parameters.scale} m / 高周波は標高段ごとに除去`);
            updateDetail(layoutInfo, `${layout.levels}段 / セル ${layout.steps.join('・')} m / 半径 ${radius.value} m / 外周半幅 ${layout.radius} m`);
            updateDetail(skyInfo, `地球・単一散乱 / 観測高度 ${(Math.max(camera.position[1] + ATMOSPHERE_REFERENCE_ALTITUDE, 2)).toFixed(0)} m / 2次SH / 強度 ${skyIntensity.value}`);
            updateDetail(shadowInfo, `2048² depth32float / 16 MiB / 光源平面 ${Number(shadowWidth.value) * 2} m四方 / Reversed Z / ${shadows.checked ? 'ON' : 'OFF'}`);
            updateDetail(updateInfo, `#${updates} / ${freeze.checked ? 'フリーズ中(標高キャッシュも固定)' : 'カメラ追従中'}`);
        }
        status.textContent = 'FP16 Height ClipmapをComputeで生成しています';
        draw();
        const validation = await device.popErrorScope();
        check();
        if (validation) {
            throw new Error(validation.message);
        }
        await device.queue.onSubmittedWorkDone();
        check();
        return { draw, dispose, regenerate: next => {
                parameters = next;
                cache?.regenerate(next);
            } };
    }
    catch (error) {
        dispose();
        throw error;
    }
}
start.addEventListener('click', async () => {
    if (busy) {
        return;
    }
    release();
    busy = true;
    start.disabled = true;
    camera = defaultCamera();
    info.replaceChildren();
    controller = new AbortController();
    const signal = controller.signal, token = generation;
    try {
        const initialized = await initialize(token, signal);
        if (token !== generation) {
            initialized.dispose();
            return;
        }
        demo = initialized;
        busy = false;
        start.disabled = false;
        start.textContent = '再読み込み';
        resetCamera.disabled = false;
        regenerate.disabled = false;
        canvas.style.touchAction = 'none';
        canvas.style.cursor = 'grab';
        status.textContent = '描画済み / 地形ビューをクリックしてWASDで移動';
        updateCameraReadout();
    }
    catch (error) {
        if (token === generation) {
            fail(error);
        }
    }
});
resetCamera.addEventListener('click', () => { clearInput(); camera = defaultCamera(); scheduleDraw(); });
for (const control of [wireframe, snapGrid, showNormals, tileColors, culling, density, radius, compaction, shadows, shadowMask, azimuth, elevation, shadowWidth, depthBias, normalBias, pcf, aerial, showSkyMap, directLight, heightCascades, heightGrid]) {
    control.addEventListener('change', scheduleDraw);
}
albedoMode.addEventListener('change', () => { albedoInputs.forEach(input => { input.disabled = albedoMode.value !== 'constant'; }); scheduleDraw(); });
freeze.addEventListener('change', () => { layoutControls.forEach(c => { c.disabled = freeze.checked; }); scheduleDraw(); });
speed.addEventListener('input', () => { speedValue.value = `${speed.value} m/s`; });
function clearInput(): void { keys.clear(); lastTime = 0; clearPointers(); }
canvas.addEventListener('keydown', event => {
    if (!demo || event.ctrlKey || event.metaKey || event.altKey) {
        return;
    }
    if (movementKeys.has(event.code) || lookKeys.has(event.code) || event.code === 'ShiftLeft' || event.code === 'ShiftRight') {
        event.preventDefault();
        keys.add(event.code);
        scheduleDraw();
    }
});
window.addEventListener('keyup', event => { keys.delete(event.code); });
canvas.addEventListener('blur', clearInput);
canvas.addEventListener('pointerdown', event => {
    if (!demo || event.button !== 0 || pointers.size) {
        return;
    }
    canvas.focus({ preventScroll: true });
    pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
    canvas.setPointerCapture(event.pointerId);
    canvas.style.cursor = 'grabbing';
    event.preventDefault();
});
canvas.addEventListener('pointermove', event => {
    const old = pointers.get(event.pointerId);
    if (!old || !demo) {
        return;
    }
    camera.yaw -= (event.clientX - old.x) * 0.004;
    camera.pitch = Math.max(-1.54, Math.min(1.54, camera.pitch + (event.clientY - old.y) * 0.004));
    pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
    event.preventDefault();
    scheduleDraw();
});
function releasePointer(event: PointerEvent): void {
    pointers.delete(event.pointerId);
    if (canvas.hasPointerCapture(event.pointerId)) {
        canvas.releasePointerCapture(event.pointerId);
    }
    canvas.style.cursor = demo ? 'grab' : 'auto';
}
for (const name of ['pointerup', 'pointercancel', 'lostpointercapture']) {
    canvas.addEventListener(name, event => {
        if (event instanceof PointerEvent) {
            releasePointer(event);
        }
    });
}
canvas.addEventListener('wheel', event => {
    if (!demo) {
        return;
    }
    event.preventDefault();
    const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? canvas.clientHeight : 1);
    moveCamera(1, 0, 0, -Math.max(-200, Math.min(200, delta)) * Number(speed.value) * 0.002);
    scheduleDraw();
}, { passive: false });
window.addEventListener('blur', clearInput);
document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        cancelAnimationFrame(frame);
        frame = 0;
        clearInput();
    }
    else {
        scheduleDraw();
    }
});
window.addEventListener('pagehide', () => { release(); status.textContent = '実行ボタンで再初期化します'; });
regenerate.addEventListener('click', () => {
    if (!demo || freeze.checked) {
        return;
    }
    try {
        demo.regenerate(terrainParameters());
        scheduleDraw();
        status.textContent = '地形を再生成しました';
    }
    catch (error) {
        status.textContent = error instanceof Error ? error.message : String(error);
    }
});
amplitudeInput.addEventListener('input', () => { amplitudeValue.value = `${amplitudeInput.value} m`; });
scaleInput.addEventListener('input', () => { scaleValue.value = `${scaleInput.value} m`; });

const lightReadouts: Array<[HTMLInputElement, HTMLOutputElement, string]> = [[azimuth, element('#gpu-light-azimuth-value', HTMLOutputElement), '°'],
    [elevation, element('#gpu-light-elevation-value', HTMLOutputElement), '°'],
    [depthBias, element('#gpu-depth-bias-value', HTMLOutputElement), ''],
    [normalBias, element('#gpu-normal-bias-value', HTMLOutputElement), ' m'],
    [skyIntensity, element('#gpu-sky-intensity-value', HTMLOutputElement), ''],
    ...albedoInputs.map((input): [HTMLInputElement, HTMLOutputElement, string] => [input, element(`#${input.id}-value`, HTMLOutputElement), '']),
    [ev100, element('#gpu-ev100-value', HTMLOutputElement), ' EV'],
    [verticalFov, element('#gpu-fov-value', HTMLOutputElement), '°']];
for (const [input, output, unit] of lightReadouts) {
    input.addEventListener('input', () => { output.value = input.value + unit; scheduleDraw(); });
}

post-process.ts

// PreExposure済みのHDR出力へ、トーンマッピングと表示色変換だけを適用する。
export class PostProcess {
    private constructor(private readonly pipeline: GPURenderPipeline, private readonly group: GPUBindGroup) {}

    static async create(device: GPUDevice, module: GPUShaderModule,
        source: GPUTextureView, format: GPUTextureFormat): Promise<PostProcess> {
        const pipeline = await device.createRenderPipelineAsync({
            label: 'HDR Tonemapping', layout: 'auto',
            vertex: { module, entryPoint: 'postVertex' },
            fragment: { module, entryPoint: 'postFragment', targets: [{ format }] },
            primitive: { topology: 'triangle-list' },
        });
        const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
            { binding: 0, resource: source },
        ] });
        return new PostProcess(pipeline, group);
    }

    encode(encoder: GPUCommandEncoder, destination: GPUTextureView): void {
        const pass = encoder.beginRenderPass({ label: 'PostProcessPass', colorAttachments: [
            { view: destination, loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] },
        ] });
        pass.setPipeline(this.pipeline);
        pass.setBindGroup(0, this.group);
        pass.draw(3);
        pass.end();
    }
}

render-math.ts

export type Vec3 = [number, number, number];
export const SHADOW_RESOLUTION = 2048;

export function dot(a: Vec3, b: Vec3): number {
    return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function cross(a: Vec3, b: Vec3): Vec3 {
    return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
export function lightDirection(azimuthDegrees: number, elevationDegrees: number): Vec3 {
    const azimuth = azimuthDegrees * Math.PI / 180;
    const elevation = elevationDegrees * Math.PI / 180;
    // 地表から光源へ向かう単位ベクトル。方位角0度は+Z、仰角0度は水平。
    return [Math.sin(azimuth) * Math.cos(elevation), Math.sin(elevation), Math.cos(azimuth) * Math.cos(elevation)];
}
export function shadowMatrix(center: Vec3, light: Vec3, halfWidth: number, depthHalfRange: number, resolution: number): Float32Array<ArrayBuffer> {
    const raw = cross([0, 1, 0], light);
    const length = Math.hypot(...raw);
    if (length < 1e-6 || halfWidth <= 0 || depthHalfRange <= 0 || resolution < 1) {
        throw new Error('シャドウ投影の条件が不正です');
    }
    const right: Vec3 = [raw[0] / length, raw[1] / length, raw[2] / length];
    const up = cross(light, right);
    const texel = 2 * halfWidth / resolution;
    // 光源平面のXYをテクセルに合わせ、カメラ追従時のサブテクセル移動を抑える。
    const cx = Math.floor(dot(right, center) / texel) * texel;
    const cy = Math.floor(dot(up, center) / texel) * texel;
    const cz = dot(light, center);
    const zScale = 1 / (2 * depthHalfRange);
    // 正射影のNDC Zは0〜1。光源に近い点ほど大きくなるReversed Z。
    return new Float32Array([
        right[0] / halfWidth, up[0] / halfWidth, light[0] * zScale, 0,
        right[1] / halfWidth, up[1] / halfWidth, light[1] * zScale, 0,
        right[2] / halfWidth, up[2] / halfWidth, light[2] * zScale, 0,
        -cx / halfWidth, -cy / halfWidth, 0.5 - cz * zScale, 1
    ]);
}
export function frustumPlanes(matrix: Float32Array): Float32Array<ArrayBuffer> {
    const out = new Float32Array(24);
    // 左右・上下・Z=0/1。無限遠Perspectiveの定数平面も正規化せず扱う。
    for (let p = 0; p < 6; p++) {
        for (let j = 0; j < 4; j++) {
            const w = matrix[j * 4 + 3];
            out[p * 4 + j] = p < 4 ? w + (p % 2 === 0 ? 1 : -1) * matrix[j * 4 + Math.floor(p / 2)]
                : p === 4 ? matrix[j * 4 + 2] : w - matrix[j * 4 + 2];
        }
    }
    return out;
}

// メインカメラと同じ右・上・前方向をピクセルレイへ渡す。平行移動は空に影響しない。
export function skyRayFrame(yaw: number, pitch: number, aspect: number, verticalFov = Math.PI / 3): Float32Array<ArrayBuffer> {
    const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch);
    const tangent = Math.tan(verticalFov / 2);
    return new Float32Array([
        -sy * cp, -sp, -cy * cp, 0,
        cy * tangent * aspect, 0, -sy * tangent * aspect, 0,
        -sy * sp * tangent, cp * tangent, -cy * sp * tangent, 0
    ]);
}

render-passes.ts

export type DepthView = 'main' | 'shadow';

// 今後のメッシュもこの契約で登録する。視点別カリングは描画物側が準備し、
// Pass側は地形固有のIB・頂点形式・可視リストを知らずに呼び出す。
export interface OpaqueRenderable {
    drawDepth(pass: GPURenderPassEncoder, view: DepthView): void;
    drawForward(pass: GPURenderPassEncoder): void;
    drawDebug(pass: GPURenderPassEncoder): void;
}
export interface BackgroundRenderable {
    draw(pass: GPURenderPassEncoder): void;
}
export interface OpaquePipelines {
    zOnly: GPURenderPipeline;
    forward: GPURenderPipeline;
    wire: GPURenderPipeline;
}
export async function createOpaquePipelines(device: GPUDevice, geometryLayout: GPUBindGroupLayout,
    lightingLayout: GPUBindGroupLayout, zOnly: GPUShaderModule, forward: GPUShaderModule,
    format: GPUTextureFormat): Promise<OpaquePipelines> {
    const depthLayout = device.createPipelineLayout({ bindGroupLayouts: [geometryLayout] });
    const shadingLayout = device.createPipelineLayout({ bindGroupLayouts: [geometryLayout, lightingLayout] });
    // 不透明物の深度用バリエーションを2つのPassで共有する。PS・色Attachmentは持たない。
    const depth = await device.createRenderPipelineAsync({ label: 'Opaque ZOnly(Main/Shadow共用)', layout: depthLayout,
        vertex: { module: zOnly, entryPoint: 'depthMain' },
        primitive: { topology: 'triangle-list', frontFace: 'ccw', cullMode: 'back' },
        depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' }
    });
    const shading = await device.createRenderPipelineAsync({ label: 'Opaque Forward Equal', layout: shadingLayout,
        vertex: { module: forward, entryPoint: 'vertexMain' },
        fragment: { module: forward, entryPoint: 'fragmentMain', targets: [{ format }] },
        primitive: { topology: 'triangle-list', frontFace: 'ccw', cullMode: 'back' },
        depthStencil: { format: 'depth32float', depthWriteEnabled: false, depthCompare: 'equal' }
    });
    const wire = await device.createRenderPipelineAsync({ label: 'デバッグワイヤーフレーム', layout: shadingLayout,
        vertex: { module: forward, entryPoint: 'wireVertexMain' },
        fragment: { module: forward, entryPoint: 'wireMain', targets: [{ format }] },
        primitive: { topology: 'line-list' },
        depthStencil: { format: 'depth32float', depthWriteEnabled: false, depthCompare: 'greater-equal' }
    });
    return { zOnly: depth, forward: shading, wire };
}
// colorはシーンリニアHDRのAttachment。Canvasへの表示は後続のPostProcessPassで行う。
export function encodeOpaquePasses(encoder: GPUCommandEncoder, color: GPUTextureView,
    mainDepth: GPUTextureView, shadowDepth: GPUTextureView, renderables: readonly OpaqueRenderable[],
    shadows: boolean, wire: boolean, background?: BackgroundRenderable): void {
    const prepass = encoder.beginRenderPass({ label: 'ZPrePass', colorAttachments: [],
        depthStencilAttachment: { view: mainDepth, depthClearValue: 0, depthLoadOp: 'clear', depthStoreOp: 'store' }
    });
    for (const object of renderables) { object.drawDepth(prepass, 'main'); }
    prepass.end();

    // メイン深度を先に確定する。将来、ここからSSAO等の深度参照処理を追加できる。
    const shadow = encoder.beginRenderPass({ label: 'ShadowPass', colorAttachments: [],
        depthStencilAttachment: { view: shadowDepth, depthClearValue: 0, depthLoadOp: 'clear', depthStoreOp: 'store' }
    });
    if (shadows) {
        for (const object of renderables) { object.drawDepth(shadow, 'shadow'); }
    }
    shadow.end();

    const forward = encoder.beginRenderPass({ label: 'ForwardShadingPass',
        colorAttachments: [{ view: color, clearValue: { r: 0.025, g: 0.05, b: 0.06, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
        // ReadOnlyのAttachmentにはload/storeを指定しない。ZPrePassの結果をEqual参照する。
        depthStencilAttachment: { view: mainDepth, depthReadOnly: true }
    });
    for (const object of renderables) { object.drawForward(forward); }
    // 最遠方のSkyを深度比較し、地形のない画素だけにピクセルレイを実行する。
    background?.draw(forward);
    if (wire) {
        for (const object of renderables) { object.drawDebug(forward); }
    }
    forward.end();
}

sky-debug.ts

// SkyLightの入力Mapを左上へ重ねる。シーンと同じPreExposure・表示変換を通す。
export class SkyDebug {
    private constructor(private readonly pipeline: GPURenderPipeline, private readonly group: GPUBindGroup) {}

    static async create(device: GPUDevice, module: GPUShaderModule, map: GPUTextureView,
        cameraBuffer: GPUBuffer): Promise<SkyDebug> {
        const pipeline = await device.createRenderPipelineAsync({ label: 'Sky Octahedralデバッグ', layout: 'auto',
            vertex: { module, entryPoint: 'debugVertex' },
            fragment: { module, entryPoint: 'debugFragment', targets: [{ format: 'rgba16float' }] },
            primitive: { topology: 'triangle-list' },
        });
        const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
            { binding: 0, resource: map }, { binding: 1, resource: { buffer: cameraBuffer } },
        ] });
        return new SkyDebug(pipeline, group);
    }

    encode(encoder: GPUCommandEncoder, target: GPUTextureView, width: number, height: number): void {
        const size = Math.min(192, width - 24, height - 24);
        if (size <= 0) { return; }
        const pass = encoder.beginRenderPass({ label: 'SkyMapDebugPass', colorAttachments: [
            { view: target, loadOp: 'load', storeOp: 'store' },
        ] });
        pass.setViewport(12, 12, size, size, 0, 1);
        pass.setPipeline(this.pipeline);
        pass.setBindGroup(0, this.group);
        pass.draw(3);
        pass.end();
    }
}

sky-environment.ts

import { skyRayFrame } from './render-math.js';
import type { Vec3 } from './render-math.js';
import type { BackgroundRenderable } from './render-passes.js';

export const SKY_MAP_SIZE = 256;
export const SKY_SH_COEFFICIENTS = 9;
export const SKY_GROUPS = SKY_MAP_SIZE * SKY_MAP_SIZE / 64;
export interface SkyModules {
    generate: GPUShaderModule;
    project: GPUShaderModule;
    reduce: GPUShaderModule;
    render: GPUShaderModule;
}
interface SkyPipelines {
    generate: GPUComputePipeline;
    project: GPUComputePipeline;
    reduce: GPUComputePipeline;
    render: GPURenderPipeline;
}
export class SkyEnvironment implements BackgroundRenderable {
    readonly map: GPUTexture;
    readonly coefficients: GPUBuffer;
    private readonly partials: GPUBuffer;
    private readonly parameters: GPUBuffer;
    private readonly view: GPUBuffer;
    private readonly generateGroup: GPUBindGroup;
    private readonly projectGroup: GPUBindGroup;
    private readonly reduceGroup: GPUBindGroup;
    private readonly renderGroup: GPUBindGroup;
    private previousLight: Vec3 | undefined;
    private previousAltitude: number | undefined;
    private disposed = false;
    revision = 0;
    private readonly device: GPUDevice;
    private readonly pipelines: SkyPipelines;

    static async create(device: GPUDevice, modules: SkyModules, format: GPUTextureFormat, cameraBuffer: GPUBuffer): Promise<SkyEnvironment> {
        const generate = await device.createComputePipelineAsync({ label: 'Sky Octahedral生成', layout: 'auto', compute: { module: modules.generate, entryPoint: 'generateSky' } });
        const project = await device.createComputePipelineAsync({ label: 'Sky SH局所投影', layout: 'auto', compute: { module: modules.project, entryPoint: 'projectSky' } });
        const reduce = await device.createComputePipelineAsync({ label: 'Sky SH最終集約', layout: 'auto', compute: { module: modules.reduce, entryPoint: 'reduceSky' } });
        const render = await device.createRenderPipelineAsync({ label: 'Skyピクセルレイ', layout: 'auto',
            vertex: { module: modules.render, entryPoint: 'skyVertex' },
            fragment: { module: modules.render, entryPoint: 'skyFragment', targets: [{ format }] },
            primitive: { topology: 'triangle-list' },
            depthStencil: { format: 'depth32float', depthWriteEnabled: false, depthCompare: 'greater-equal' }
        });
        return new SkyEnvironment(device, { generate, project, reduce, render }, cameraBuffer);
    }
    private constructor(device: GPUDevice, pipelines: SkyPipelines, cameraBuffer: GPUBuffer) {
        this.device = device;
        this.pipelines = pipelines;
        this.map = device.createTexture({ label: 'Sky Octahedral 256²', size: [SKY_MAP_SIZE, SKY_MAP_SIZE], format: 'rgba16float',
            usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC });
        this.coefficients = device.createBuffer({ label: 'Sky 2次SH 9係数RGB', size: SKY_SH_COEFFICIENTS * 16,
            usage: GPUBufferUsage.STORAGE | GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_SRC });
        this.partials = device.createBuffer({ label: 'Sky SH局所和', size: SKY_GROUPS * SKY_SH_COEFFICIENTS * 16, usage: GPUBufferUsage.STORAGE });
        this.parameters = device.createBuffer({ label: 'Sky生成条件', size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
        this.view = device.createBuffer({ label: 'Skyピクセルレイの基底', size: 48, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
        this.generateGroup = device.createBindGroup({ layout: pipelines.generate.getBindGroupLayout(0), entries: [
            { binding: 0, resource: { buffer: this.parameters } }, { binding: 1, resource: this.map.createView() }
        ] });
        this.projectGroup = device.createBindGroup({ layout: pipelines.project.getBindGroupLayout(0), entries: [
            { binding: 0, resource: this.map.createView() }, { binding: 1, resource: { buffer: this.partials } }
        ] });
        this.reduceGroup = device.createBindGroup({ layout: pipelines.reduce.getBindGroupLayout(0), entries: [
            { binding: 0, resource: { buffer: this.partials } }, { binding: 1, resource: { buffer: this.coefficients } }
        ] });
        this.renderGroup = device.createBindGroup({ layout: pipelines.render.getBindGroupLayout(0), entries: [
            { binding: 0, resource: { buffer: this.parameters } }, { binding: 1, resource: { buffer: this.view } }, { binding: 2, resource: { buffer: cameraBuffer } }
        ] });
    }
    encode(encoder: GPUCommandEncoder, light: Vec3, yaw: number, pitch: number, aspect: number, observerAltitude = 200, verticalFov = Math.PI / 3): boolean {
        const rays = skyRayFrame(yaw, pitch, aspect, verticalFov);
        this.device.queue.writeBuffer(this.view, 0, rays);
        const changed = this.previousAltitude !== observerAltitude || !this.previousLight || light.some((value, i) => value !== this.previousLight?.[i]);
        if (!changed) { return false; }
        this.device.queue.writeBuffer(this.parameters, 0, new Float32Array([...light, 0, observerAltitude, 0, 0, 0]));
        // 各段の依存をCompute Pass境界で区切る。係数のCPU読戻しやグローバルAtomicは不要。
        const generate = encoder.beginComputePass({ label: 'SkyOctahedralPass' });
        generate.setPipeline(this.pipelines.generate);
        generate.setBindGroup(0, this.generateGroup);
        generate.dispatchWorkgroups(SKY_MAP_SIZE / 8, SKY_MAP_SIZE / 8);
        generate.end();
        const project = encoder.beginComputePass({ label: 'SkySHProjectPass' });
        project.setPipeline(this.pipelines.project);
        project.setBindGroup(0, this.projectGroup);
        project.dispatchWorkgroups(SKY_GROUPS);
        project.end();
        const reduce = encoder.beginComputePass({ label: 'SkySHReducePass' });
        reduce.setPipeline(this.pipelines.reduce);
        reduce.setBindGroup(0, this.reduceGroup);
        reduce.dispatchWorkgroups(1);
        reduce.end();
        this.previousLight = [...light];
        this.previousAltitude = observerAltitude;
        this.revision++;
        return true;
    }
    draw(pass: GPURenderPassEncoder): void {
        pass.setPipeline(this.pipelines.render);
        pass.setBindGroup(0, this.renderGroup);
        pass.draw(3);
    }
    dispose(): void {
        if (this.disposed) { return; }
        this.disposed = true;
        this.map.destroy();
        this.coefficients.destroy();
        this.partials.destroy();
        this.parameters.destroy();
        this.view.destroy();
    }
}