v0.7.0: SDL-free wasm worker backend (WebGPU + WebGL2) with on-demand assets

The engine now runs on a plain Web Worker (no SDL, no PROXY_TO_PTHREAD: in a pthread Emscripten proxies every filesystem syscall to the main browser thread, where synchronous XHR - and thus FS.createLazyFile - is forbidden). A page/worker pair transfers an OffscreenCanvas and forwards DOM input; assets load lazily from an embedded manifest, so entering a map fetches only that map and its tiles.

Presentation: ra3.webgpu (WebGPU via Emscripten's emdawnwebgpu port, WGSL shaders, the default on wasm) and ra3.wasmgl (WebGL2, GLSL ES). The legacy SDL ra3.webgl backend is removed.
This commit is contained in:
EnderTheCoder
2026-09-29 12:08:07 +08:00
parent 70d4beca4f
commit 889ce945f2
27 changed files with 2161 additions and 639 deletions
+6 -4
View File
@@ -130,17 +130,19 @@ float4 PSMain(VSOut input) : SV_Target {
return float4(sky_color(dir), 1.0);
}
// March the heightfield (bounded work: the step grows toward the horizon).
// March the heightfield. The step grows quickly: the map diagonal is only
// ~9000 world units, so marching past ~20000 adds cost without detail, and
// a 6% growth rate more than doubles the worst-case iteration count.
float t = CELL * 0.5;
float dt = CELL * 0.5;
float prev = t;
bool hit = false;
float hit_t = 0.0;
for (int i = 0; i < 512 && t < 60000.0; ++i) {
for (int i = 0; i < 256 && t < 20000.0; ++i) {
float3 w = cam_pos + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t;
dt *= 1.06;
dt *= 1.10;
t += dt;
continue;
}
@@ -155,7 +157,7 @@ float4 PSMain(VSOut input) : SV_Target {
break;
}
prev = t;
dt *= 1.06;
dt *= 1.10;
t += dt;
}
if (!hit) {
Binary file not shown.
+6 -4
View File
@@ -116,20 +116,22 @@ void main() {
return;
}
// March the heightfield (bounded work: the step grows toward the horizon).
// March the heightfield. The step grows quickly: the map diagonal is only
// ~9000 world units, so marching past ~20000 adds cost without detail, and
// a 6% growth rate more than doubles the worst-case iteration count.
float t = CELL * 0.5;
float dt = CELL * 0.5;
float prev = t;
bool hit = false;
float hit_t = 0.0;
for (int i = 0; i < 512 && t < 60000.0; ++i) {
for (int i = 0; i < 256 && t < 20000.0; ++i) {
vec3 w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t; dt *= 1.06; t += dt; continue;
prev = t; dt *= 1.10; t += dt; continue;
}
if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; }
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; }
prev = t; dt *= 1.06; t += dt;
prev = t; dt *= 1.10; t += dt;
}
if (!hit) { out_color = vec4(sky_color(dir), 1.0); return; }
+6 -3
View File
@@ -109,16 +109,19 @@ void main() {
return;
}
// March the heightfield. The step grows quickly: the map diagonal is only
// ~9000 world units, so marching past ~20000 adds cost without detail, and
// a 6% growth rate more than doubles the worst-case iteration count.
float t = CELL * 0.5;
float dt = CELL * 0.5;
float prev = t;
bool hit = false;
float hit_t = 0.0;
for (int i = 0; i < 512 && t < 60000.0; ++i) {
for (int i = 0; i < 256 && t < 20000.0; ++i) {
vec3 w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t;
dt *= 1.06;
dt *= 1.10;
t += dt;
continue;
}
@@ -133,7 +136,7 @@ void main() {
break;
}
prev = t;
dt *= 1.06;
dt *= 1.10;
t += dt;
}
if (!hit) {
+38
View File
@@ -0,0 +1,38 @@
// WebGPU counterpart of the 2D image path (the WGSL port of
// webgl_scene_vert/frag.glsl and the Vulkan scene shaders).
//
// Bind group 0: uniform { rect: vec4<f32>; }, the scene texture and a sampler.
// WebGPU clip space is +Y up, matching GL, so the vertex flips Y the same way.
struct SceneUniforms {
rect: vec4<f32>, // xy = top-left (0..1), zw = size (0..1)
};
@group(0) @binding(0) var<uniform> u_scene: SceneUniforms;
@group(0) @binding(1) var u_scene_tex: texture_2d<f32>;
@group(0) @binding(2) var u_scene_samp: sampler;
struct SceneOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> SceneOut {
let p = vec2<f32>(f32((vertex_index << 1u) & 2u), f32(vertex_index & 2u));
var out: SceneOut;
out.uv = (p - u_scene.rect.xy) / u_scene.rect.zw;
out.position = vec4<f32>(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: SceneOut) -> @location(0) vec4<f32> {
// Only the destination rect is drawn; outside it the previous contents stay.
if (in.uv.x < 0.0 || in.uv.x > 1.0 || in.uv.y < 0.0 || in.uv.y > 1.0) {
discard;
}
// The image is 0xAARRGGBB (BGRA in memory); swizzle it back.
let c = textureSampleLevel(u_scene_tex, u_scene_samp, in.uv, 0.0);
return vec4<f32>(c.b, c.g, c.r, c.a);
}
+229
View File
@@ -0,0 +1,229 @@
// WebGPU counterpart of the GPU heightfield raymarcher (the WGSL port of
// webgl_terrain_frag.glsl / terrain.frag). The Vulkan push constants become a
// uniform buffer of five vec4s, laid out exactly as `ra3::wasmgl::detail::terrain_uniforms`.
//
// Bind group 0: uniform data[5], the R16 heightmap (u32), the RGBA16 cell record
// (vec4<u32>), the RGBA8 tile atlas (array), and an atlas sampler.
struct TerrainUniforms {
data: array<vec4<f32>, 5>,
};
@group(0) @binding(0) var<uniform> u: TerrainUniforms;
@group(0) @binding(1) var u_heightmap: texture_2d<u32>; // R16Uint heights
@group(0) @binding(2) var u_celldata: texture_2d<u32>; // RGBA16Uint blend record (texel is vec4<u32>)
@group(0) @binding(3) var u_atlas: texture_2d_array<f32>; // RGBA8 tile materials
@group(0) @binding(4) var u_atlas_samp: sampler;
const CELL: f32 = 10.0; // must match ra3::terrain::cell_size
fn cam_uniform() -> vec4<f32> { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height
fn params_uniform() -> vec4<f32> { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water
fn sun_uniform() -> vec4<f32> { return u.data[2]; } // xyz=sun dir, w=ambient
fn mapinfo_uniform() -> vec4<f32> { return u.data[3]; }// x=W, y=H, z=unused, w=z_scale
fn misc_uniform() -> vec4<f32> { return u.data[4]; } // x=time, y=unused, z=cells per repeat, w=aspect
struct TerrainOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> TerrainOut {
let p = vec2<f32>(f32((vertex_index << 1u) & 2u), f32(vertex_index & 2u));
var out: TerrainOut;
out.uv = p;
out.position = vec4<f32>(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
return out;
}
fn height_at(cell: vec2<i32>) -> f32 {
let limit = vec2<i32>(mapinfo_uniform().xy) - vec2<i32>(1);
let c = clamp(cell, vec2<i32>(0), limit);
return f32(textureLoad(u_heightmap, c, 0).r) * mapinfo_uniform().w;
}
fn world_height(wx: f32, wy: f32) -> f32 {
let world_w = mapinfo_uniform().x * CELL;
let world_h = mapinfo_uniform().y * CELL;
if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) {
return -1.0e9;
}
let c = vec2<i32>(i32(wx / CELL), i32((world_h - wy) / CELL));
return height_at(c);
}
fn sky_color(dir: vec3<f32>) -> vec3<f32> {
let d = normalize(dir);
let sun_dir = normalize(sun_uniform().xyz);
let t = clamp(d.z, 0.0, 1.0);
let horizon = vec3<f32>(0.70, 0.78, 0.85);
let zenith = vec3<f32>(0.28, 0.48, 0.80);
var col = mix(horizon, zenith, pow(t, 0.6));
let sun = max(dot(d, sun_dir), 0.0);
col += vec3<f32>(1.0, 0.95, 0.82) * pow(sun, 300.0) * 1.6;
col += vec3<f32>(1.0, 0.90, 0.72) * pow(sun, 8.0) * 0.18;
return col;
}
// The retail SAGE blend ramp (see terrain.frag).
fn blend_factor(direction: u32, flags: u32, f_in: vec2<f32>) -> f32 {
var f = f_in;
let flipped = (flags & 1u) != 0u;
let two_sided = (flags & 2u) != 0u;
if (flipped) {
if (direction == 1u) {
f.x = 1.0 - f.x;
} else if (direction == 2u || direction == 4u || direction == 8u) {
f.y = 1.0 - f.y;
}
}
if (direction == 1u) { return f.x; }
if (direction == 2u) { return f.y; }
if (direction == 4u) {
let s = (1.0 - f.x) + (1.0 - f.y);
return select(clamp(1.0 - s, 0.0, 1.0), 1.0 - clamp(s - 1.0, 0.0, 1.0), two_sided);
}
if (direction == 8u) {
let s = f.x + (1.0 - f.y);
return select(clamp(1.0 - s, 0.0, 1.0), 1.0 - clamp(s - 1.0, 0.0, 1.0), two_sided);
}
return 0.0;
}
fn sample_layer(layer: u32, wx: f32, wy: f32) -> vec3<f32> {
let span = max(misc_uniform().z, 1.0);
let layer_count = textureNumLayers(u_atlas);
let l = f32(min(layer, layer_count - 1u));
// The atlas is 0xAARRGGBB (BGRA in memory), uploaded as RGBA8.
let c = textureSampleLevel(u_atlas, u_atlas_samp, vec2<f32>(wx, wy) / span, i32(l), 0.0);
return vec3<f32>(c.b, c.g, c.r);
}
@fragment
fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
let p = cam_uniform();
let pitch = clamp(params_uniform().x, 0.15, 1.45);
let fov = clamp(params_uniform().y, 0.3, 1.4);
let world_w = mapinfo_uniform().x * CELL;
let world_h = mapinfo_uniform().y * CELL;
let cp = cos(pitch);
let fwd = vec3<f32>(cp * sin(p.z), cp * cos(p.z), -sin(pitch));
let right = normalize(cross(fwd, vec3<f32>(0.0, 0.0, 1.0)));
let up = cross(right, fwd);
var target_z = world_height(p.x, p.y);
if (target_z < -1.0e8) { target_z = 0.0; }
let dist = p.w / sin(pitch);
let cam = vec3<f32>(p.x, p.y, target_z + p.w) - fwd * dist;
let ndc = vec2<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
let aspect = misc_uniform().w;
let th = tan(fov * 0.5);
let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) {
return vec4<f32>(sky_color(dir), 1.0);
}
// March the heightfield. The step grows quickly: the map diagonal is only
// ~9000 world units, so marching past ~20000 adds cost without detail.
var t = CELL * 0.5;
var dt = CELL * 0.5;
var prev = t;
var hit = false;
var hit_t = 0.0;
for (var i = 0; i < 256 && t < 20000.0; i = i + 1) {
let w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t;
dt *= 1.10;
t += dt;
continue;
}
if (params_uniform().w > 0.5 && w.z <= params_uniform().z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
hit = true;
hit_t = t;
break;
}
prev = t;
dt *= 1.10;
t += dt;
}
if (!hit) {
return vec4<f32>(sky_color(dir), 1.0);
}
var lo = prev;
var hi = hit_t;
for (var i = 0; i < 6; i = i + 1) {
let mid = 0.5 * (lo + hi);
let w = cam + dir * mid;
let water = params_uniform().w > 0.5 && w.z <= params_uniform().z;
if (water || w.z <= world_height(w.x, w.y)) {
hi = mid;
} else {
lo = mid;
}
}
let hitpos = cam + dir * hi;
let sun = normalize(sun_uniform().xyz);
let ambient = sun_uniform().w;
if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) {
let time = misc_uniform().x;
let q = hitpos.xy * 0.015;
let nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
let ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
let n = normalize(vec3<f32>(nx * 0.06, ny * 0.06, 1.0));
let fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
let deep = vec3<f32>(0.03, 0.16, 0.28);
let refl = sky_color(reflect(dir, n));
let lam = max(0.0, dot(n, sun));
var water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += vec3<f32>(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6;
let wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = mix(water, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), wfog);
return vec4<f32>(water, 1.0);
}
let wx = hitpos.x / CELL;
let wy = (world_h - hitpos.y) / CELL;
let cx = clamp(i32(wx), 0, i32(mapinfo_uniform().x) - 1);
let cy = clamp(i32(wy), 0, i32(mapinfo_uniform().y) - 1);
let fx = wx - floor(wx);
let fy = wy - floor(wy);
let record = textureLoad(u_celldata, vec2<i32>(cx, cy), 0);
let packed = record.w;
let dir1 = packed & 0xFu;
let flags1 = (packed >> 4u) & 0x3u;
let dir2 = (packed >> 8u) & 0xFu;
let flags2 = (packed >> 12u) & 0x3u;
let frac_uv = vec2<f32>(fx, fy);
let c0 = sample_layer(record.x, wx, wy);
let c1 = sample_layer(record.y, wx, wy);
let c2 = sample_layer(record.z, wx, wy);
let f1 = blend_factor(dir1, flags1, frac_uv);
let f2 = blend_factor(dir2, flags2, frac_uv);
let albedo = mix(mix(c0, c1, f1), c2, f2);
let hl = world_height(hitpos.x - CELL, hitpos.y);
let hr = world_height(hitpos.x + CELL, hitpos.y);
let hd = world_height(hitpos.x, hitpos.y - CELL);
let hu = world_height(hitpos.x, hitpos.y + CELL);
let n = normalize(vec3<f32>(hl - hr, hd - hu, 2.0 * CELL));
let lambert = max(0.0, dot(n, sun));
var lit = albedo * (ambient + (1.0 - ambient) * lambert);
let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = mix(lit, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), fog);
return vec4<f32>(lit, 1.0);
}