render.wgsl
@group(0) @binding(1) var<storage,read> terrain: array<u32>;
@group(0) @binding(2) var<storage,read> plants: array<Plant>;
@group(0) @binding(3) var<storage,read> visible: array<u32>;
@group(0) @binding(4) var<storage,read> sunlight: array<u32>;
struct Vertex { @builtin(position) clip: vec4f, @location(0) normal: vec3f, @location(1) color: vec3f }
@vertex fn voxel(@builtin(vertex_index) v: u32, @builtin(instance_index) instance: u32) -> Vertex {
let i = visible[instance];
let base=vec3f(coordinate(i));
let corners = array<vec2f,6>(vec2f(0,0),vec2f(1,0),vec2f(1,1),vec2f(0,0),vec2f(1,1),vec2f(0,1));
let uv = corners[v%6u];
var p: vec3f; var n: vec3f;
switch v/6u {
case 0u: { p=vec3f(uv.x,uv.y,1); n=vec3f(0,0,1); }
case 1u: { p=vec3f(1-uv.x,uv.y,0); n=vec3f(0,0,-1); }
case 2u: { p=vec3f(uv.x,1,1-uv.y); n=vec3f(0,1,0); }
case 3u: { p=vec3f(uv.x,0,uv.y); n=vec3f(0,-1,0); }
case 4u: { p=vec3f(1,uv.y,1-uv.x); n=vec3f(1,0,0); }
default: { p=vec3f(0,uv.y,uv.x); n=vec3f(-1,0,0); }
}
p=(p-vec3f(0.5))*0.98+vec3f(0.5);
let plant=plants[i];
let variation=0.88+0.12*random(i*29u);
var color=vec3f(0.12,0.23,0.25)*variation;
if(terrain[i]==0u) {
let hue=random(plant.colony*71u);
color=mix(vec3f(0.12,0.24,0.045),vec3f(0.10,0.52,0.20),plant.energy/maximumEnergy);
color*=0.8+0.4*hue;
if(settings.display.x==3u) { color=mix(vec3f(0.08,0.3,0.85),vec3f(0.85,0.15,0.06),plant.upProbability); }
if(settings.display.x==4u) { color=mix(vec3f(0.15,0.2,0.3),vec3f(0.85,0.65,0.12),min(f32(plant.generation)/10.0,1.0)); }
if(settings.display.x==1u) { color=mix(vec3f(0.6,0.07,0.025),vec3f(0.1,0.75,0.25),plant.energy/maximumEnergy); }
}
if(settings.display.x==2u) { color=select(vec3f(0.09,0.12,0.19),vec3f(0.85,0.7,0.2),sunlight[i]!=0u); }
return Vertex(settings.vp*vec4f(base+p,1),n,color);
}
@fragment fn shade(v: Vertex) -> @location(0) vec4f {
let diffuse=max(dot(normalize(v.normal),normalize(vec3f(-0.4,0.9,0.5))),0.0);
let linear=v.color*(0.35+0.65*diffuse);
return vec4f(pow(clamp(linear,vec3f(0),vec3f(1)),vec3f(1.0/2.2)),1);
}
@vertex fn bounds(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
let edges=array<vec2u,12>(vec2u(0,1),vec2u(1,3),vec2u(3,2),vec2u(2,0),vec2u(4,5),vec2u(5,7),vec2u(7,6),vec2u(6,4),vec2u(0,4),vec2u(1,5),vec2u(2,6),vec2u(3,7));
let pair=edges[v/2u]; let c=select(pair.x,pair.y,v%2u==1u);
let p=vec3f(f32(c&1u),f32((c>>1u)&1u),f32((c>>2u)&1u))*vec3f(settings.world.xyz);
return settings.vp*vec4f(p,1);
}
@fragment fn line() -> @location(0) vec4f { return vec4f(0.24,0.36,0.38,1); }
simulation.wgsl
@group(0) @binding(1) var<storage,read_write> terrain: array<u32>;
@group(0) @binding(2) var<storage,read> plants: array<Plant>;
@group(0) @binding(3) var<storage,read_write> nextPlants: array<Plant>;
@group(0) @binding(4) var<storage,read_write> sunlight: array<u32>;
@group(0) @binding(5) var<storage,read_write> energies: array<f32>;
@group(0) @binding(6) var<storage,read_write> seeds: array<atomic<u32>>;
// Seeded value noise and analytic tunnel fields; no per-map coordinate presets.
fn lattice(p: vec3i, salt: u32) -> f32 {
let key=(bitcast<u32>(p.x)*73856093u) ^ (bitcast<u32>(p.y)*19349663u) ^ (bitcast<u32>(p.z)*83492791u);
return random(key ^ settings.display.y ^ salt);
}
fn noise(p: vec3f, salt: u32) -> f32 {
let cell=vec3i(floor(p));let t=fract(p);let u=t*t*(vec3f(3)-2.0*t);
let a=mix(lattice(cell,salt),lattice(cell+vec3i(1,0,0),salt),u.x);
let b=mix(lattice(cell+vec3i(0,1,0),salt),lattice(cell+vec3i(1,1,0),salt),u.x);
let c=mix(lattice(cell+vec3i(0,0,1),salt),lattice(cell+vec3i(1,0,1),salt),u.x);
let d=mix(lattice(cell+vec3i(0,1,1),salt),lattice(cell+vec3i(1,1,1),salt),u.x);
return mix(mix(a,b,u.y),mix(c,d,u.y),u.z);
}
fn parameter(lane: u32) -> f32 { return random(settings.display.y ^ (lane*2654435761u)); }
fn terrainAt(p: vec3u) -> bool {
if(p.y<=1u) { return true; }
let size=vec3f(settings.world.xyz);
let point=vec3f(p)+vec3f(0.5);
let q=point/size;
let warp=vec2f(noise(vec3f(q.x*3.0,0,q.z*3.0),17u),noise(vec3f(q.x*3.0,0,q.z*3.0),29u))-vec2f(0.5);
let landscape=noise(vec3f(q.x*3.5+warp.x,0,q.z*3.5+warp.y),41u);
let ridge=1.0-abs(2.0*noise(vec3f(q.x*2.1,0,q.z*2.1),73u)-1.0);
let height=size.y*(0.10+0.22*landscape+0.23*ridge);
// A true 3D field, not a height-only surface: vertical bands bulge and recede.
let detail=(noise(point/vec3f(18,9,18),101u)-0.5)*size.y*0.22;
let ledges=sin(point.y*0.32+landscape*5.0)*(1.0-q.y)*3.0;
if(point.y>height+detail+ledges) { return false; }
for(var tunnel=0u;tunnel<4u;tunnel++) {
let lane=100u+tunnel*11u;
let alongX=parameter(lane)>0.5;
let along=select(point.z,point.x,alongX);
let across=select(point.x,point.z,alongX);
let acrossSize=select(size.x,size.z,alongX);
let phase=parameter(lane+1u)*6.2831853;
let centerAcross=acrossSize*(0.18+parameter(lane+2u)*0.64)+sin(along/(18.0+parameter(lane+3u)*20.0)+phase)*acrossSize*0.10;
let centerY=size.y*(0.14+parameter(lane+4u)*0.18)+sin(along/31.0+phase)*size.y*0.035;
let radius=size.y*(0.065+parameter(lane+5u)*0.045);
let delta=vec2f((across-centerAcross)/radius,(point.y-centerY)/(radius*0.8));
if(dot(delta,delta)<1.0) { return false; }
}
return true;
}
@compute @workgroup_size(64)
fn initialize(@builtin(global_invocation_id) id: vec3u) {
let i=id.x;if(i>=settings.world.w) { return; }
terrain[i]=select(0u,1u,terrainAt(coordinate(i)));
nextPlants[i]=emptyPlant();sunlight[i]=0u;energies[i]=0.0;
}
// Separate dispatch: all terrain writes complete before roots inspect support cells.
@compute @workgroup_size(64)
fn seedPlants(@builtin(global_invocation_id) id: vec3u) {
let i=id.x;if(i>=settings.world.w) { return; }
let p=coordinate(i);
if(terrain[i]==0u && p.y>0u && terrain[address(p-vec3u(0,1,0))]!=0u && random(i ^ settings.display.y)<0.004) {
nextPlants[i]=Plant(5.0,0.0,0u,1u,0u,i+1u,random(i ^ settings.display.y ^ 9127u),0u);
}
}
// One invocation owns an entire vertical column; no cross-workgroup synchronization.
@compute @workgroup_size(64)
fn illuminate(@builtin(global_invocation_id) id: vec3u) {
let column=id.x; if(column>=settings.world.x*settings.world.z) { return; }
let x=column%settings.world.x; let z=column/settings.world.x;
var open=true;
for(var y=i32(settings.world.y)-1; y>=0; y--) {
let i=address(vec3u(x,u32(y),z));
sunlight[i]=select(0u,1u,open);
if(terrain[i]!=0u || plants[i].energy>0.0) { open=false; }
}
}
fn attached(i: u32) -> bool {
let plant=plants[i]; let p=coordinate(i);
if(plant.parent==0u) { return p.y>0u && terrain[address(p-vec3u(0,1,0))]!=0u; }
let parent=plant.parent-1u;
if(parent>=settings.world.w) { return false; }
let source=plants[parent];
return source.energy>0.0 && source.birth==plant.parentBirth && source.colony==plant.colony;
}
@compute @workgroup_size(64)
fn metabolize(@builtin(global_invocation_id) id: vec3u) {
let i=id.x; if(i>=settings.world.w) { return; }
let plant=plants[i]; energies[i]=-1.0;
if(plant.energy<=0.0 || !attached(i)) { return; }
let lifetime=settings.clock.w;
if(lifetime>0.0 && plant.age+settings.clock.x>=lifetime) { return; }
let p=vec3i(coordinate(i));
var exchange=0.0;
for(var k=0u;k<6u;k++) {
let q=p+neighbor(k); if(!inside(q)) { continue; }
let other=plants[address(vec3u(q))];
if(other.energy>0.0) { exchange+=(other.energy-plant.energy)*transportRate; }
}
// Opposite ends evaluate equal and opposite flux from the same old snapshot.
// 6*transportRate*dt <= 1 keeps the diffusion update non-negative.
let gain=select(0.0,2.4,sunlight[i]!=0u);
let maintenance=0.16+0.0015*f32(p.y);
energies[i]=clamp(plant.energy+settings.clock.x*(exchange+gain-maintenance),0.0,maximumEnergy);
}
fn proposal(i: u32) -> u32 {
if(plants[i].energy<=0.0 || energies[i]<maximumEnergy) { return settings.world.w; }
let p=vec3i(coordinate(i));
let seed=i*131u+u32(settings.clock.y)*977u+settings.display.y;
var direction=0u;
if(random(seed)>=plants[i].upProbability) { direction=1u+u32(random(seed ^ 5171u)*4.0); }
let q=p+neighbor(direction);
if(inside(q)) {
let destination=address(vec3u(q));
if(terrain[destination]==0u && plants[destination].energy<=0.0) { return destination; }
}
return settings.world.w;
}
fn winner(destination: u32) -> u32 {
let p=vec3i(coordinate(destination));
var best=settings.world.w; var priority=2.0;
for(var k=0u;k<5u;k++) {
let q=p-neighbor(k); if(!inside(q)) { continue; }
let candidate=address(vec3u(q));
if(proposal(candidate)!=destination) { continue; }
let rank=random(candidate*67u+destination*13u+u32(settings.clock.y));
if(rank<priority || (rank==priority && candidate<best)) { best=candidate; priority=rank; }
}
return best;
}
// Seed paths and eligibility use one immutable snapshot.
fn seedTarget(i: u32) -> u32 {
if(plants[i].energy<=0.0 || energies[i]<8.0 || sunlight[i]==0u) { return settings.world.w; }
let key=i*197u+u32(settings.clock.y)*1237u+settings.display.y;
if(random(key ^ 9719u)>=0.05*settings.clock.x) { return settings.world.w; }
var p=vec3i(coordinate(i));
if(random(key ^ 373u)<0.3) { p+=neighbor(1u+u32(random(key ^ 817u)*4.0)); }
if(!inside(p)) { return settings.world.w; }
let start=address(vec3u(p));
if(start!=i && (terrain[start]!=0u || plants[start].energy>0.0)) { return settings.world.w; }
for(var y=p.y-1;y>=0;y--) {
let hit=address(vec3u(u32(p.x),u32(y),u32(p.z)));
if(plants[hit].energy>0.0) { return settings.world.w; }
if(terrain[hit]!=0u) {
let landing=address(vec3u(u32(p.x),u32(y+1),u32(p.z)));
if(landing==i || plants[landing].energy>0.0) { return settings.world.w; }
return landing;
}
}
return settings.world.w;
}
@compute @workgroup_size(64)
fn disperse(@builtin(global_invocation_id) id: vec3u) {
let i=id.x;if(i>=settings.world.w) { return; }
let landing=seedTarget(i);
// Stable cell-index priority, independent of invocation scheduling.
if(landing<settings.world.w) { atomicMax(&seeds[landing],i+1u); }
}
@compute @workgroup_size(64)
fn grow(@builtin(global_invocation_id) id: vec3u) {
let i=id.x; if(i>=settings.world.w) { return; }
var next=emptyPlant();
if(terrain[i]!=0u) { nextPlants[i]=next; return; }
let plant=plants[i];
if(plant.energy>0.0) {
let energy=energies[i];
if(energy>0.0) {
next=plant; next.energy=energy; next.age+=settings.clock.x;
let destination=proposal(i);
if(destination<settings.world.w && winner(destination)==i) { next.energy-=birthCost; }
let landing=seedTarget(i);
if(landing<settings.world.w && atomicLoad(&seeds[landing])==i+1u && winner(landing)==settings.world.w) { next.energy-=4.0; }
}
} else {
let parent=winner(i);
if(parent<settings.world.w) {
let source=plants[parent];
next=Plant(childEnergy,0.0,parent+1u,u32(settings.clock.y)+2u,source.birth,source.colony,source.upProbability,source.generation);
} else {
let seedParent=atomicLoad(&seeds[i]);
if(seedParent!=0u) {
let source=plants[seedParent-1u];
let key=i*317u+seedParent*127u+u32(settings.clock.y)*991u+settings.display.y;
var gene=source.upProbability;
if(random(key ^ 3191u)<0.1) { gene=clamp(gene+(random(key ^ 7717u)*2.0-1.0)*0.1,0.0,1.0); }
next=Plant(2.0,0.0,0u,u32(settings.clock.y)+2u,0u,i+1u,gene,source.generation+1u);
} else {
let p=coordinate(i);
if(p.y>0u && terrain[address(p-vec3u(0,1,0))]!=0u && sunlight[i]!=0u &&
random(i*17u+u32(settings.clock.y)*991u+settings.display.y)<settings.ecology.x*settings.clock.x) {
next=Plant(3.0,0.0,0u,u32(settings.clock.y)+2u,0u,i+1u,random(i ^ u32(settings.clock.y) ^ settings.display.y ^ 9127u),0u);
}
}
}
}
nextPlants[i]=next;
}
surface.wgsl
@group(0) @binding(1) var<storage,read> terrain: array<u32>;
@group(0) @binding(2) var<storage,read> plants: array<Plant>;
@group(0) @binding(3) var<storage,read_write> visible: array<u32>;
struct Draw { vertices: u32, instances: atomic<u32>, firstVertex: u32, firstInstance: u32 }
@group(0) @binding(4) var<storage,read_write> draw: Draw;
fn occupied(p: vec3i) -> bool {
if(!inside(p)) { return false; }
let i=address(vec3u(p));return terrain[i]!=0u || plants[i].energy>0.0;
}
@compute @workgroup_size(64)
fn compact(@builtin(global_invocation_id) id: vec3u) {
let i=id.x;if(i>=settings.world.w) { return; }
let p=vec3i(coordinate(i));if(!occupied(p)) { return; }
var exposed=false;
for(var k=0u;k<6u;k++) { if(!occupied(p+neighbor(k))) { exposed=true;break; } }
if(exposed) { visible[atomicAdd(&draw.instances,1u)]=i; }
}
world.wgsl
// Shared declarations local to this experiment. Dimensions come only from TypeScript settings.
struct Settings { vp: mat4x4f, world: vec4u, clock: vec4f, display: vec4u, ecology: vec4f }
struct Plant { energy: f32, age: f32, parent: u32, birth: u32, parentBirth: u32, colony: u32, upProbability: f32, generation: u32 }
const maximumEnergy=12.0;
const birthCost=4.0;
const childEnergy=2.0;
const transportRate=0.4;
@group(0) @binding(0) var<uniform> settings: Settings;
fn inside(p: vec3i) -> bool { return all(p>=vec3i(0)) && all(p<vec3i(settings.world.xyz)); }
fn address(p: vec3u) -> u32 { return p.x+settings.world.x*(p.z+settings.world.z*p.y); }
fn coordinate(i: u32) -> vec3u { return vec3u(i%settings.world.x,i/(settings.world.x*settings.world.z),(i/settings.world.x)%settings.world.z); }
fn random(seed: u32) -> f32 {
var h=seed; h=(h^(h>>16u))*0x7feb352du; h=(h^(h>>15u))*0x846ca68bu; h=h^(h>>16u);
return f32(h>>8u)/16777216.0;
}
fn emptyPlant() -> Plant { return Plant(0,0,0u,0u,0u,0u,0.5,0u); }
fn neighbor(k: u32) -> vec3i {
let offsets=array<vec3i,6>(vec3i(0,1,0),vec3i(1,0,0),vec3i(-1,0,0),vec3i(0,0,1),vec3i(0,0,-1),vec3i(0,-1,0));
return offsets[k];
}
controls.txt
<style>
.voxel-controls{display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin:20px 0}.voxel-controls input{accent-color:var(--accent)}.voxel-controls label{display:flex;gap:8px;align-items:center}.voxel-controls select{font:inherit;background:var(--paper);color:var(--ink)}
</style>
<div class="voxel-controls">
<button id="gpu-stop" type="button" disabled>停止</button>
<button id="gpu-reset" type="button" disabled>最初から</button>
<button id="gpu-camera-reset" type="button" disabled>カメラを戻す</button>
<label>シード <input id="gpu-seed" type="number" min="0" max="4294967295" step="1" value="37" style="width:11em"></label>
<button id="gpu-regenerate" type="button" disabled>再生成</button>
<button id="gpu-random-seed" type="button">別シード</button>
<label>表示 <select id="gpu-view" disabled><option value="0">植物</option><option value="1">エネルギー</option><option value="2">日照</option><option value="3">遺伝子(青:横 / 赤:上)</option><option value="4">世代</option></select></label>
<label>新規発生率 <input id="gpu-spawn-rate" type="range" min="0" max="0.02" step="0.0001" value="0.002"><output id="gpu-spawn-rate-value">0.0020% / 秒 / セル</output></label>
<label>寿命 <input id="gpu-lifetime" type="range" min="0" max="600" step="5" value="105"><output id="gpu-lifetime-value">105秒</output>(0で無効)</label>
<label>時間倍率 <input id="gpu-speed" type="range" min="1" max="16" step="1" value="1" disabled><output id="gpu-speed-value">1×</output></label>
</div>
main.ts
export {};
type Vec3 = [number, number, number];
function element<T extends Element>(id: string, type: { new(): T }): T {
const value = document.querySelector(id);
if (!(value instanceof type)) throw new Error(`要素が見つかりません: ${id}`);
return value;
}
const start = element('#gpu-start', HTMLButtonElement);
const stop = element('#gpu-stop', HTMLButtonElement);
const reset = element('#gpu-reset', HTMLButtonElement);
const cameraReset = element('#gpu-camera-reset', HTMLButtonElement);
const speed = element('#gpu-speed', HTMLInputElement);
const spawnRateControl=element('#gpu-spawn-rate',HTMLInputElement);
const spawnRateValue=element('#gpu-spawn-rate-value',HTMLOutputElement);
const lifetimeControl=element('#gpu-lifetime',HTMLInputElement);
const lifetimeValue=element('#gpu-lifetime-value',HTMLOutputElement);
const seedControl=element('#gpu-seed',HTMLInputElement);
const regenerate=element('#gpu-regenerate',HTMLButtonElement);
const randomSeed=element('#gpu-random-seed',HTMLButtonElement);
let activeSeed=37;
function readSeed(): number {
const value=seedControl.valueAsNumber;
if(!Number.isInteger(value) || value<0 || value>0xffffffff) throw new Error('シードは0〜4294967295の整数を入力してください');
return value;
}
const viewControl=element('#gpu-view',HTMLSelectElement);
const speedValue = element('#gpu-speed-value', HTMLOutputElement);
const canvas = element('#gpu-canvas', HTMLCanvasElement);
const status = element('#gpu-status', HTMLParagraphElement);
const info = element('#gpu-info', HTMLDListElement);
const initialCamera = { yaw: 0.72, pitch: 0.57, distance: 220 };
let camera = { ...initialCamera };
let running = false, busy = false, generation = 0, frame = 0, steps = 0;
const world={x:128,y:64,z:128};
const cellCount=world.x*world.y*world.z;
const stepSeconds=0.25;
let viewDirty=true;
let previous: number | undefined, accumulator = 0;
interface Demo { step(): void; draw(): void; reset(): void; dispose(): void }
let demo: Demo | undefined;
function normalize(v: Vec3): Vec3 { const length = Math.hypot(...v); return [v[0]/length,v[1]/length,v[2]/length]; }
function cross(a: Vec3,b: Vec3): Vec3 { return [a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]; }
function dot(a: Vec3,b: Vec3): number { return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]; }
function viewProjection(): Float32Array<ArrayBuffer> {
const z: Vec3 = [Math.sin(camera.yaw)*Math.cos(camera.pitch),Math.sin(camera.pitch),Math.cos(camera.yaw)*Math.cos(camera.pitch)];
const x = normalize(cross([0,1,0],z)), y = cross(z,x);
const eye: Vec3 = [world.x/2+z[0]*camera.distance,world.y*0.4+z[1]*camera.distance,world.z/2+z[2]*camera.distance];
const f=1/Math.tan(Math.PI/6),sx=f/(canvas.width/canvas.height);
// Perspective Reversed Z, near=0.5 and infinite far: clip.z=near, clip.w=-view.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,0.5,dot(z,eye)]);
}
function updateStatus(): void {
status.textContent = `${running ? '実行中' : '停止中'} · ${(steps*stepSeconds).toFixed(1)}秒 · 128×64×128`;
}
function pause(): void {
running=false; cancelAnimationFrame(frame); previous=undefined;
stop.disabled=true; start.disabled=busy; start.textContent=demo ? '再開' : '実行';
}
function fail(error: unknown): void {
busy=false; pause(); demo?.dispose(); demo=undefined; generation++;
for (const control of [reset,cameraReset,speed,regenerate,viewControl]) control.disabled=true;
seedControl.disabled=false;randomSeed.disabled=false;
canvas.style.touchAction='auto'; start.textContent='実行';
status.textContent=`実行できませんでした: ${error instanceof Error ? error.message : String(error)}`;
}
async function initialize(token: number): Promise<Demo> {
if (!window.isSecureContext || !navigator.gpu) throw new Error('WebGPU対応ブラウザでHTTPSまたはlocalhostから開いてください');
const check = (): void => { if (generation!==token) throw new Error('初期化を中断しました'); };
const adapter=await navigator.gpu.requestAdapter(); check();
if (!adapter) throw new Error('GPUアダプターを取得できませんでした');
const device=await adapter.requestDevice();
const buffers: GPUBuffer[]=[];
let context: GPUCanvasContext | null=null, depth: GPUTexture | undefined;
const dispose=(): void => { if (token===generation) context?.unconfigure(); buffers.forEach(b=>b.destroy()); depth?.destroy(); device.destroy(); };
try {
check(); context=canvas.getContext('webgpu');
if (!context) throw new Error('WebGPU Canvasを取得できませんでした');
const activeContext=context, format=navigator.gpu.getPreferredCanvasFormat();
context.configure({device,format,alphaMode:'opaque'});
device.lost.then(({reason,message})=>{ if(token===generation && reason!=='destroyed') fail(new Error(`GPUデバイスを失いました: ${message || reason}`)); });
device.addEventListener('uncapturederror',event=>{if(token===generation) fail(new Error(event.error.message));});
async function source(name: string): Promise<string> {
const response=await fetch(new URL(name,import.meta.url));
if(!response.ok) throw new Error(`${name}: HTTP ${response.status}`);
const bytes=new Uint8Array(await response.arrayBuffer());
if(bytes[0]===239 && bytes[1]===187 && bytes[2]===191) throw new Error(`${name}: UTF-8 BOMは使用できません`);
let code: string;
try {code=new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(bytes);} catch {throw new Error(`${name}: 不正なUTF-8です`);}
check(); return code;
}
const declarations=await source('world.wgsl');
async function shader(name: string): Promise<GPUShaderModule> {
const code=declarations+'\n'+await source(name);
device.pushErrorScope('validation');
const module=device.createShaderModule({code});
const compilation=await module.getCompilationInfo();
const validation=await device.popErrorScope(); check();
const errors=compilation.messages.filter(m=>m.type==='error');
if(errors.length || validation) throw new Error(`${name}: ${errors.map(m=>`${m.lineNum}: ${m.message}`).join('; ') || validation?.message}`);
return module;
}
const simulation=await shader('simulation.wgsl'), surface=await shader('surface.wgsl'), render=await shader('render.wgsl');
device.pushErrorScope('validation');
function buffer(size: number,usage: GPUBufferUsageFlags): GPUBuffer {const b=device.createBuffer({size,usage});buffers.push(b);return b;}
const terrain=buffer(cellCount*4,GPUBufferUsage.STORAGE);
const plants=[buffer(cellCount*32,GPUBufferUsage.STORAGE),buffer(cellCount*32,GPUBufferUsage.STORAGE)];
const seeds=buffer(cellCount*4,GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST);
const sunlight=buffer(cellCount*4,GPUBufferUsage.STORAGE);
const energies=buffer(cellCount*4,GPUBufferUsage.STORAGE);
const visible=buffer(cellCount*4,GPUBufferUsage.STORAGE);
const indirect=buffer(16,GPUBufferUsage.STORAGE|GPUBufferUsage.INDIRECT|GPUBufferUsage.COPY_DST);
const uniform=buffer(128,GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST);
const simulationLayout=device.createBindGroupLayout({entries:[
{binding:0,visibility:GPUShaderStage.COMPUTE,buffer:{type:'uniform'}},
...[1,2,3,4,5,6].map(binding=>({binding,visibility:GPUShaderStage.COMPUTE,buffer:{type:binding===2?'read-only-storage':'storage'}} satisfies GPUBindGroupLayoutEntry)),
]});
const simulationPipelineLayout=device.createPipelineLayout({bindGroupLayouts:[simulationLayout]});
async function compute(entryPoint: string): Promise<GPUComputePipeline> {
return device.createComputePipelineAsync({layout:simulationPipelineLayout,compute:{module:simulation,entryPoint}});
}
const initializePipeline=await compute('initialize'), lightPipeline=await compute('illuminate');
const seedPipeline=await compute('seedPlants'), dispersePipeline=await compute('disperse');
const energyPipeline=await compute('metabolize'), growthPipeline=await compute('grow');
const simulationGroups=plants.map((old,index)=>device.createBindGroup({layout:simulationLayout,
entries:[uniform,terrain,old,plants[1-index],sunlight,energies,seeds].map((b,binding)=>({binding,resource:{buffer:b}}))}));
const compactPipeline=await device.createComputePipelineAsync({layout:'auto',compute:{module:surface,entryPoint:'compact'}});
const compactGroups=plants.map(plant=>device.createBindGroup({layout:compactPipeline.getBindGroupLayout(0),entries:[uniform,terrain,plant,visible,indirect].map((b,binding)=>({binding,resource:{buffer:b}}))}));
const renderLayout=device.createBindGroupLayout({entries:[
{binding:0,visibility:GPUShaderStage.VERTEX,buffer:{type:'uniform'}},
...[1,2,3,4].map(binding=>({binding,visibility:GPUShaderStage.VERTEX,buffer:{type:'read-only-storage'}} satisfies GPUBindGroupLayoutEntry)),
]});
const renderPipelineLayout=device.createPipelineLayout({bindGroupLayouts:[renderLayout]});
const renderGroups=plants.map(plant=>device.createBindGroup({layout:renderLayout,entries:[uniform,terrain,plant,visible,sunlight].map((b,binding)=>({binding,resource:{buffer:b}}))}));
async function pipeline(vertex: string,fragment: string,topology: GPUPrimitiveTopology): Promise<GPURenderPipeline> {
return device.createRenderPipelineAsync({layout:renderPipelineLayout,vertex:{module:render,entryPoint:vertex},fragment:{module:render,entryPoint:fragment,targets:[{format}]},
primitive:{topology,cullMode:'back'},depthStencil:{format:'depth32float',depthWriteEnabled:topology==='triangle-list',depthCompare:'greater-equal'}});
}
const voxelPipeline=await pipeline('voxel','shade','triangle-list');
const boundsPipeline=await pipeline('bounds','line','line-list');
depth=device.createTexture({size:[canvas.width,canvas.height],format:'depth32float',usage:GPUTextureUsage.RENDER_ATTACHMENT});
const depthView=depth.createView();
const validation=await device.popErrorScope(); check(); if(validation) throw new Error(validation.message);
const settings=new ArrayBuffer(128), floats=new Float32Array(settings), integers=new Uint32Array(settings);
let current=1, surfaceDirty=true;
const workgroups=Math.ceil(cellCount/64),columns=Math.ceil(world.x*world.z/64);
function writeSettings(): void {
floats.set(viewProjection()); integers.set([world.x,world.y,world.z,cellCount],16);
floats.set([stepSeconds,steps,steps*stepSeconds,Number(lifetimeControl.value)],20);
integers.set([Number(viewControl.value),activeSeed,0,0],24);
floats.set([Number(spawnRateControl.value)/100,0,0,0],28);
device.queue.writeBuffer(uniform,0,settings);
}
function resetWorld(): void {
element('#gpu-seed-info',HTMLElement).textContent=String(activeSeed);
steps=0;accumulator=0;previous=undefined;current=1;surfaceDirty=true;viewDirty=true;writeSettings();
device.queue.writeBuffer(indirect,0,new Uint32Array([36,0,0,0]));
const encoder=device.createCommandEncoder(), pass=encoder.beginComputePass();
pass.setPipeline(initializePipeline);pass.setBindGroup(0,simulationGroups[0]);pass.dispatchWorkgroups(workgroups);
pass.setPipeline(seedPipeline);pass.dispatchWorkgroups(workgroups);
pass.setPipeline(lightPipeline);pass.setBindGroup(0,simulationGroups[current]);pass.dispatchWorkgroups(columns);
pass.end();device.queue.submit([encoder.finish()]);
}
function step(): void {
writeSettings();const encoder=device.createCommandEncoder();encoder.clearBuffer(seeds);const pass=encoder.beginComputePass();
pass.setBindGroup(0,simulationGroups[current]);
pass.setPipeline(lightPipeline);pass.dispatchWorkgroups(columns);
pass.setPipeline(energyPipeline);pass.dispatchWorkgroups(workgroups);
pass.setPipeline(dispersePipeline);pass.dispatchWorkgroups(workgroups);
pass.setPipeline(growthPipeline);pass.dispatchWorkgroups(workgroups);
current=1-current;
pass.setBindGroup(0,simulationGroups[current]);pass.setPipeline(lightPipeline);pass.dispatchWorkgroups(columns);
pass.end();device.queue.submit([encoder.finish()]);steps++;surfaceDirty=true;
}
function draw(): void {
writeSettings();const encoder=device.createCommandEncoder();
if(surfaceDirty) {
encoder.clearBuffer(indirect,4,4);
const compute=encoder.beginComputePass();compute.setPipeline(compactPipeline);compute.setBindGroup(0,compactGroups[current]);compute.dispatchWorkgroups(workgroups);compute.end();surfaceDirty=false;
}
const pass=encoder.beginRenderPass({colorAttachments:[{view:activeContext.getCurrentTexture().createView(),clearValue:{r:0.025,g:0.055,b:0.065,a:1},loadOp:'clear',storeOp:'store'}],
depthStencilAttachment:{view:depthView,depthClearValue:0,depthLoadOp:'clear',depthStoreOp:'store'}});
pass.setBindGroup(0,renderGroups[current]);pass.setPipeline(voxelPipeline);pass.drawIndirect(indirect,0);
pass.setPipeline(boundsPipeline);pass.draw(24);pass.end();device.queue.submit([encoder.finish()]);viewDirty=false;
}
info.replaceChildren();
for(const [label,value] of [['空間','128×64×128 / 1,048,576セル'],['シード',String(activeSeed)],['更新','固定0.25秒 / 日照・エネルギー・成長をGPUで更新'],['状態','植物32バイト×2面 / 毎フレームのCPU読出しなし'],['GPU',adapter.info.description || adapter.info.device || 'ブラウザから機種名は提供されていません']]) {
const dt=document.createElement('dt'),dd=document.createElement('dd');dt.textContent=label;dd.textContent=value;if(label==='シード')dd.id='gpu-seed-info';info.append(dt,dd);
}
resetWorld();draw();return {step,draw,reset:resetWorld,dispose};
} catch(error) {dispose();throw error;}
}
function tick(timestamp: number): void {
if(!running || !demo) return;
try {
if(previous!==undefined) accumulator=Math.min(accumulator+Math.min((timestamp-previous)/1000,0.5)*Number(speed.value),stepSeconds*4);
previous=timestamp;let count=0;
while(accumulator>=stepSeconds && count<4) {demo.step();accumulator-=stepSeconds;count++;}
if(count>0 || viewDirty)demo.draw();updateStatus();frame=requestAnimationFrame(tick);
} catch(error) {fail(error);}
}
start.addEventListener('click',async()=>{
if(busy || running) return;
try {if(!demo)activeSeed=readSeed();} catch(error) {status.textContent=error instanceof Error ? error.message : String(error);return;}
busy=true;start.disabled=true;seedControl.disabled=true;randomSeed.disabled=true;const token=generation;
try {
if(!demo) {status.textContent='WebGPUを初期化しています';const initialized=await initialize(token);if(token!==generation){initialized.dispose();return;}demo=initialized;}
busy=false;seedControl.disabled=false;randomSeed.disabled=false;running=true;previous=undefined;stop.disabled=false;
for(const control of [reset,cameraReset,speed,regenerate,viewControl]) control.disabled=false;
canvas.style.touchAction='none';canvas.style.cursor='grab';updateStatus();frame=requestAnimationFrame(tick);
} catch(error) {if(token===generation) fail(error);}
});
stop.addEventListener('click',()=>{pause();updateStatus();});
reset.addEventListener('click',()=>{if(demo){demo.reset();demo.draw();updateStatus();}});
function regenerateWorld(): void {
try {
const seed=readSeed();
if(demo){activeSeed=seed;demo.reset();demo.draw();updateStatus();}
} catch(error) {status.textContent=error instanceof Error ? error.message : String(error);}
}
regenerate.addEventListener('click',regenerateWorld);
seedControl.addEventListener('keydown',event=>{if(event.key==='Enter' && !busy)regenerateWorld();});
randomSeed.addEventListener('click',()=>{
seedControl.value=String(crypto.getRandomValues(new Uint32Array(1))[0]);
if(demo)regenerateWorld();
});
viewControl.addEventListener('change',()=>{demo?.draw();});
spawnRateControl.addEventListener('input',()=>{spawnRateValue.value=Number(spawnRateControl.value)===0?'無効':`${Number(spawnRateControl.value).toFixed(4)}% / 秒 / セル`;});
lifetimeControl.addEventListener('input',()=>{lifetimeValue.value=Number(lifetimeControl.value)===0?'無効':`${lifetimeControl.value}秒`;});
speed.addEventListener('input',()=>{speedValue.value=`${speed.value}×`;});
cameraReset.addEventListener('click',()=>{camera={...initialCamera};demo?.draw();});
const pointers=new Map<number,{x:number;y:number}>();
function clearPointers(): void {for(const id of [...pointers.keys()]){pointers.delete(id);if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id);}}
function distance(): number {const p=[...pointers.values()];return p.length===2 ? Math.hypot(p[0].x-p[1].x,p[0].y-p[1].y):0;}
function zoom(scale: number): void {camera.distance=Math.max(70,Math.min(420,camera.distance*scale));}
canvas.addEventListener('pointerdown',event=>{if(!demo || event.button!==0 || pointers.size>=2)return;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;const before=distance();pointers.set(event.pointerId,{x:event.clientX,y:event.clientY});
if(pointers.size===1){camera.yaw-=(event.clientX-old.x)*0.008;camera.pitch=Math.max(0.12,Math.min(1.4,camera.pitch+(event.clientY-old.y)*0.008));}
else {const after=distance();if(before>0 && after>0)zoom(before/after);}
event.preventDefault();viewDirty=true;if(!running)demo.draw();
});
function release(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)release(event);});
canvas.addEventListener('wheel',event=>{if(!demo)return;event.preventDefault();const delta=event.deltaY*(event.deltaMode===1?16:event.deltaMode===2?canvas.clientHeight:1);zoom(Math.exp(Math.max(-1,Math.min(1,delta*0.001))));viewDirty=true;if(!running)demo.draw();},{passive:false});
window.addEventListener('blur',clearPointers);
document.addEventListener('visibilitychange',()=>{if(document.hidden && running){pause();clearPointers();updateStatus();}});
window.addEventListener('pagehide',()=>{busy=false;pause();clearPointers();demo?.dispose();demo=undefined;generation++;start.textContent='実行';seedControl.disabled=false;randomSeed.disabled=false;for(const control of [reset,cameraReset,speed,regenerate,viewControl])control.disabled=true;canvas.style.touchAction='auto';});