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];
    // 細かい側の境界補正による移動を含むよう、2セル分の保守的な余裕を取る。
    let lo = c.xy + vec2f(tile.coord) * 64.0 * c.z - vec2f(2.0 * c.z);
    let hi = c.xy + vec2f(tile.coord + vec2i(1)) * 64.0 * c.z + vec2f(2.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));
}

terrain.wgsl

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

struct VertexOutput {
    @builtin(position) clip: vec4f,
    @location(0) normal: vec3f,
    @location(1) height: f32,
    @location(2) @interpolate(flat) tileID: 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ごとのリング判定を省く。
// 原点差が細かい側の1セル分あっても角が反転しないよう、外周4行で補正する。
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];
        let half = f32(scene.layoutInfo.x * 32u);
        let weights = clamp((abs(vec2f(grid)) - (half - 4.0)) * 0.25, vec2f(0.0), vec2f(1.0));
        // 原点差は境界の法線方向へ、偶奇頂点の集約は接線方向へ反映する。
        // 負の格子座標でも、ビットANDで偶奇を0または1として求められる。
        let parity = vec2f(grid & vec2i(1));
        xz += max(weights.x, weights.y) * (coarse.xy - c.xy)
            - weights.yx * parity * c.z;
        // 集約前の頂点番号ではなく、補正後の同じ世界位置には同じ標高混合率を与える。
        // 四隅で異なる格子行の頂点が同一点へ集まっても、立体的な縮退を維持する。
        let distance = max(abs(xz.x - coarse.x), abs(xz.y - coarse.y));
        weight = clamp((distance - (c.w - 4.0 * c.z)) / (4.0 * c.z), 0.0, 1.0);
    }
    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);
}

@vertex
fn vertexMain(@builtin(vertex_index) id: u32, @builtin(instance_index) instance: u32) -> VertexOutput {
    let surface = terrainVertex(id, instance);
    // 外周では法線参照の間隔も粗い段へ合わせ、隣接する段との陰影差を抑える。
    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);
}

@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);
}

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;
}

// 面・線のPSではdiscard/clip、深度出力、Storage書込を行わない。
@fragment
fn fragmentMain(@location(0) normal: vec3f, @location(1) height: f32,
    @location(2) @interpolate(flat) tileID: u32) -> @location(0) vec4f {
    let n = normalize(normal);
    if (scene.elevation.z != 0.0) { return vec4f(n * 0.5 + 0.5, 1.0); }
    let t = clamp((height - scene.elevation.x) * scene.elevation.y, 0.0, 1.0);
    let albedo = select(vec3f(mix(0.16, 0.94, t)), tileColor(tileID), scene.elevation.w != 0.0);
    let linear = albedo * (0.28 + 0.72 * max(dot(n, normalize(scene.light.xyz)), 0.0));
    return vec4f(pow(linear, vec3f(1.0 / 2.2)), 1.0);
}

@fragment
fn wireMain() -> @location(0) vec4f {
    return vec4f(0.04, 0.055, 0.065, 1.0);
}

tile-common.wgsl

// 共有Uniformは480バイト。各項目のバイト位置はmain.tsを参照。
struct Scene {
    viewProjection: mat4x4f,
    planes: array<vec4f, 6>,
    light: vec4f,
    elevation: vec4f, // 最低標高、標高幅の逆数、法線表示、タイル色分け。
    domain: vec4f, // 標高窓サイズ、標高段数、最細間隔、予約。
    terrain: vec4f, // 標高原点、最低/最高標高の保守的範囲、予約。
    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
    );
}

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 - 4 / 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 - 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];
        out.set([snap ? Math.floor(x / s) * s : x, snap ? Math.floor(z / s) * s : 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参照実装。すべての頂点を通常タイルの格子内に保持する。
// 段ごとに独立した全スナップ位相で角が反転しないよう、外周4行で補正する。
// 接線方向の偶奇集約と、境界の法線方向への原点移動には別々の重みを使う。
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 half = layout.density * 32;
        const wx = Math.max(0, Math.min(1, (Math.abs(u) - (half - 4)) / 4));
        const wz = Math.max(0, Math.min(1, (Math.abs(v) - (half - 4)) / 4));
        const w = Math.max(wx, wz);
        x += w * (origins[(l + 1) * 4] - ox) - wz * (u - 2 * Math.floor(u / 2)) * s;
        z += w * (origins[(l + 1) * 4 + 1] - oz) - wx * (v - 2 * Math.floor(v / 2)) * s;
        const distance = Math.max(Math.abs(x - origins[(l + 1) * 4]), Math.abs(z - origins[(l + 1) * 4 + 1]));
        weight = Math.max(0, Math.min(1, (distance - (origins[l * 4 + 3] - 4 * s)) / (4 * s)));
    }
    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

<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin:20px 0">
<button id="gpu-camera-reset" type="button" disabled>カメラを戻す</button>
<label><input id="gpu-snap" type="checkbox" checked> セルスナップ</label>
<label><input id="gpu-normals" type="checkbox"> 法線表示</label>
<label><input id="gpu-wireframe" type="checkbox"> ワイヤーフレーム</label>
<label><input id="gpu-tile-colors" type="checkbox"> タイル色分け</label>
<label><input id="gpu-culling" type="checkbox" checked> フラスタムカリング</label>
<label><input id="gpu-freeze" type="checkbox"> カリング・配置をフリーズ</label>
<label>タイル密度 <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>カバー半径 <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>地形シード <input id="gpu-seed" type="number" min="0" max="4294967295" step="1" value="12343725" style="width:110px"></label>
<label>標高振幅 <input id="gpu-amplitude" type="range" min="100" max="2000" step="50" value="2000"> <output id="gpu-amplitude-value">2000 m</output></label>
<label>山スケール <input id="gpu-scale" type="range" min="500" max="8000" step="100" value="3100"> <output id="gpu-scale-value">3100 m</output></label>
<button id="gpu-regenerate" type="button" disabled>地形を再生成</button>
<label>可視リスト集約 <select id="gpu-compaction"><option value="auto">自動(subgroup優先)</option><option value="workgroup">workgroup(比較用)</option></select></label>
<label>移動速度 <input id="gpu-speed" type="range" min="10" max="600" step="10" value="150"> <output id="gpu-speed-value">150 m/s</output></label>
</div>
<p>ビューをクリックして操作:W/S 前後・A/D 左右・Q/E 下降/上昇・Shift 加速<br>ドラッグ/矢印キーで視点回転・ホイールで前後移動。地形との衝突判定はありません</p>
<p>カメラ位置:<output id="gpu-camera-value">未初期化</output></p>

<p>シード・標高振幅・山スケールの変更は「地形を再生成」で反映します。格納先は循環しますが、地形は繰り返しません</p>

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 { 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);
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 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 compaction = element('#gpu-compaction', HTMLSelectElement);
const layoutControls = [snapGrid, culling, density, radius, compaction, seedInput, amplitudeInput, scaleInput, regenerate];
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(): 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(Math.PI / 6), 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(label: string, value: string): HTMLElement {
    const dt = document.createElement('dt'), dd = document.createElement('dd');
    dt.textContent = label;
    dd.textContent = value;
    info.append(dt, dd);
    return dd;
}
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 cache: HeightClipmap | 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();
        retired.forEach(old => old.dispose());
        buffers.forEach(buffer => buffer.destroy());
        depth?.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 renderShader = await shader('terrain.wgsl');
        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);
        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);
        const wireIndirect = buffer(20, GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC);
        device.queue.writeBuffer(indirect, 0, new Uint32Array([grid.surface.length, 0, 0, 0, 0]));
        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;
        const computeGroup = device.createBindGroup({ layout: computeLayout, entries: [
                { binding: 0, resource: { buffer: uniform } },
                { binding: 1, resource: { buffer: visible } },
                { binding: 2, resource: { buffer: indirect } }
            ] });
        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 pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [groupLayout] });
        const pipeline = await device.createRenderPipelineAsync({ layout: pipelineLayout,
            vertex: { module: renderShader, entryPoint: 'vertexMain' }, fragment: { module: renderShader, entryPoint: 'fragmentMain', targets: [{ format }] },
            primitive: { topology: 'triangle-list', frontFace: 'ccw', cullMode: 'back' },
            depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' } });
        const wirePipeline = await device.createRenderPipelineAsync({ layout: pipelineLayout,
            vertex: { module: renderShader, entryPoint: 'wireVertexMain' }, fragment: { module: renderShader, entryPoint: 'wireMain', targets: [{ format }] },
            primitive: { topology: 'line-list' }, depthStencil: { format: 'depth32float', depthWriteEnabled: false, depthCompare: 'greater-equal' } });
        check();
        let layout = createLayout(Number(density.value), Number(radius.value));
        let origins = cascadeParameters(camera.position[0], camera.position[2], layout, snapGrid.checked);
        let group: GPUBindGroup;
        function replaceCache(count: number): HeightClipmap {
            const previous = cache;
            const next = new HeightClipmap(device, heightPipeline, count, parameters);
            cache = next;
            group = device.createBindGroup({ layout: groupLayout, entries: [
                    { binding: 0, resource: { buffer: uniform } },
                    { binding: 1, resource: { buffer: next.buffer } },
                    { binding: 2, resource: { buffer: visible } }
                ] });
            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();
        // 旧来のScene先頭368 Bを維持し、末尾112 Bに7段の標高窓原点を追加する。
        const settings = new Float32Array(120);
        const integers = new Uint32Array(settings.buffer);
        const signed = new Int32Array(settings.buffer);
        let updates = 0;
        let pending = false;
        info.replaceChildren();
        const heightInfo = detail('Height Clipmap', '');
        const heightUpdateInfo = detail('標高生成', '');
        const generationInfo = detail('地形', '');
        detail('共有タイル', `${grid.vertexCount.toLocaleString()}頂点ID / ${(grid.surface.length / 3).toLocaleString()}三角形 / VB 0 B`);
        detail('タイル共有IB', `uint16 / タイル描画 ${indices.size.toLocaleString()} B + ワイヤーフレーム ${wireIndices.size.toLocaleString()} B`);
        detail('GPU描画データ', `可視タイル ${visible.size.toLocaleString()} B / Indirect 40 B / Scene 480 B / 読戻し40 B`);
        const compactInfo = detail('可視リスト集約', '');
        const layoutInfo = detail('メッシュLOD', '');
        const countInfo = detail('カリング', 'GPU結果を取得中');
        const updateInfo = detail('配置・カリング更新', '');
        detail('GPU', adapter.info.description || adapter.info.device || 'ブラウザから機種名は提供されていません');
        function draw(): void {
            if (disposed) {
                return;
            }
            const matrix = viewProjection();
            settings.set(matrix);
            const live = !freeze.checked || updates === 0;
            const encoder = device.createCommandEncoder();
            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);
                }
                for (let p = 0; p < 6; p++) {
                    for (let j = 0; j < 4; j++) {
                        const w = matrix[j * 4 + 3];
                        settings[16 + 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];
                    }
                }
            }
            const activeCache = cache;
            if (!activeCache) {
                throw new Error('Height Clipmapが初期化されていません');
            }
            if (live) {
                const stats = activeCache.encode(encoder, camera.position[0], camera.position[2]);
                heightUpdateInfo.textContent = `${stats.levels}段 / ${stats.samples.toLocaleString()}サンプル / ${stats.rectangles}矩形(前回の描画で更新)`;
            }
            const bound = terrainBound(parameters);
            settings.set([-0.4, 0.8, -0.6, 0], 40);
            settings.set([-bound, 1 / (2 * bound), Number(showNormals.checked), Number(tileColors.checked)], 44);
            settings.set([HEIGHT_SIZE, activeCache.count, 1, 0], 48);
            settings.set([0, -bound, bound, 0], 52);
            integers.set([layout.density, layout.levels, layout.count, Number(culling.checked)], 56);
            settings.set(origins, 60);
            signed.set(activeCache.windows, 92);
            device.queue.writeBuffer(uniform, 0, settings);
            if (live) {
                const compute = compaction.value === 'auto' && subgroupCompute ? subgroupCompute : workgroupCompute;
                compactInfo.textContent = compute === subgroupCompute ? 'subgroup集約 / 非空subgroupごとに1 AtomicAdd' : 'workgroup共有メモリscan / 非空workgroupごとに1 AtomicAdd';
                encoder.clearBuffer(indirect, 4, 4);
                const pass = encoder.beginComputePass();
                pass.setPipeline(compute);
                pass.setBindGroup(0, computeGroup);
                pass.dispatchWorkgroups(Math.ceil(layout.count / 64));
                pass.end();
                encoder.copyBufferToBuffer(indirect, 4, wireIndirect, 4, 4);
                updates++;
            }
            const pass = encoder.beginRenderPass({ colorAttachments: [{ view: activeContext.getCurrentTexture().createView(),
                        clearValue: { r: 0.025, g: 0.05, b: 0.06, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
                depthStencilAttachment: { view: depthView, depthClearValue: 0, depthLoadOp: 'clear', depthStoreOp: 'store' } });
            pass.setPipeline(pipeline);
            pass.setBindGroup(0, group);
            pass.setIndexBuffer(indices, 'uint16');
            pass.drawIndexedIndirect(indirect, 0);
            if (wireframe.checked) {
                pass.setPipeline(wirePipeline);
                pass.setIndexBuffer(wireIndices, 'uint16');
                pass.drawIndexedIndirect(wireIndirect, 0);
            }
            pass.end();
            // 状態表示だけを非同期に読む。標高・可視数のCPU取得を待って描画する経路はない。
            const read = !pending;
            const revision = updates;
            const candidates = layout.count;
            if (read) {
                pending = true;
                encoder.copyBufferToBuffer(indirect, 0, readback, 0, 20);
                encoder.copyBufferToBuffer(wireIndirect, 0, readback, 20, 20);
            }
            device.queue.submit([encoder.finish()]);
            if (read) {
                void readback.mapAsync(GPUMapMode.READ).then(() => {
                    const values = new Uint32Array(readback.getMappedRange());
                    const count = values[1];
                    readback.unmap();
                    pending = false;
                    if (!disposed && revision === updates) {
                        countInfo.textContent = `可視 ${count} / 候補 ${candidates} タイル`;
                    }
                    else if (!disposed) {
                        scheduleDraw();
                    }
                }).catch(error => { pending = false; if (!disposed && token === generation) {
                    fail(error);
                } });
            }
            heightInfo.textContent = `1024×1024 × ${activeCache.count}段 / FP16 ${(activeCache.bytes / 1048576).toFixed(0)} MiB / 間隔 ${Array.from({ length: activeCache.count }, (_, l) => 2 ** l).join('・')} m`;
            generationInfo.textContent = `seed ${parameters.seed} / 振幅 ${parameters.amplitude} m / 山スケール ${parameters.scale} m / 高周波は標高段ごとに除去`;
            layoutInfo.textContent = `${layout.levels}段 / セル ${layout.steps.join('・')} m / 半径 ${radius.value} m / 外周半幅 ${layout.radius} m`;
            updateInfo.textContent = `#${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]) {
    control.addEventListener('change', 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`; });