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