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.
206 lines
8.1 KiB
GLSL
206 lines
8.1 KiB
GLSL
#version 450
|
|
|
|
// GPU heightfield raymarcher for the real RA3 terrain.
|
|
//
|
|
// Textures: heightmap (R16), a per-cell blend record (R16G16B16A16_UNORM: base
|
|
// layer, blend layer, three-way layer, packed direction/flags; unpacked with
|
|
// *65535) and a texture array of the tile materials (RGBA8, mipmapped, REPEAT).
|
|
// The material is sampled **continuously** (`uv = cell / (2 * cellSize)`), as the
|
|
// retail `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a
|
|
// cell edge; material boundaries cross-fade with the SAGE blend ramp.
|
|
layout(binding = 0) uniform sampler2D heightmap;
|
|
layout(binding = 1) uniform sampler2D celldata;
|
|
layout(binding = 2) uniform sampler2DArray atlas;
|
|
|
|
layout(push_constant) uniform Push {
|
|
vec4 cam; // x=target_x, y=target_y, z=yaw, w=height
|
|
vec4 params; // x=pitch, y=fov, z=water_z, w=has_water
|
|
vec4 sun; // xyz=sun dir, w=ambient
|
|
vec4 mapinfo; // x=W, y=H, z=unused, w=z_scale
|
|
vec4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect
|
|
} pc;
|
|
|
|
layout(location = 0) in vec2 in_uv;
|
|
layout(location = 0) out vec4 out_color;
|
|
|
|
const float CELL = 10.0; // must match ra3::terrain::cell_size
|
|
|
|
float height_at(ivec2 c) {
|
|
c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1);
|
|
return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w;
|
|
}
|
|
|
|
float world_height(float wx, float wy) {
|
|
float world_w = pc.mapinfo.x * CELL;
|
|
float world_h = pc.mapinfo.y * CELL;
|
|
if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) return -1.0e9;
|
|
ivec2 c = ivec2(int(wx / CELL), int((world_h - wy) / CELL));
|
|
return height_at(c);
|
|
}
|
|
|
|
vec3 sky_color(vec3 dir) {
|
|
vec3 d = normalize(dir);
|
|
vec3 sun_dir = normalize(pc.sun.xyz);
|
|
float t = clamp(d.z, 0.0, 1.0);
|
|
vec3 horizon = vec3(0.70, 0.78, 0.85);
|
|
vec3 zenith = vec3(0.28, 0.48, 0.80);
|
|
vec3 col = mix(horizon, zenith, pow(t, 0.6));
|
|
float sun = max(dot(d, sun_dir), 0.0);
|
|
col += vec3(1.0, 0.95, 0.82) * pow(sun, 300.0) * 1.6; // sun disk
|
|
col += vec3(1.0, 0.90, 0.72) * pow(sun, 8.0) * 0.18; // glow
|
|
return col;
|
|
}
|
|
|
|
// The retail SAGE blend ramp: 0 at one edge of the cell, 1 at the opposite.
|
|
// Direction: 1 right, 2 top, 4 top-right, 8 top-left; flag bit 0 flips,
|
|
// bit 1 marks a two-sided diagonal.
|
|
float blend_factor(uint direction, uint flags, vec2 f) {
|
|
bool flipped = (flags & 1u) != 0u;
|
|
bool 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) {
|
|
float s = (1.0 - f.x) + (1.0 - f.y);
|
|
return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0);
|
|
}
|
|
if (direction == 8u) {
|
|
float s = f.x + (1.0 - f.y);
|
|
return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0);
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
// Sample one tile material layer at global cell coordinates. The texture repeats
|
|
// every `span` cells (SAGE `uv / (cellSize * 2)`) with REPEAT addressing, so it
|
|
// never restarts at a cell edge.
|
|
vec3 sample_layer(uint layer, float wx, float wy) {
|
|
float span = max(pc.misc.z, 1.0);
|
|
int layer_count = textureSize(atlas, 0).z;
|
|
float l = float(min(layer, uint(layer_count - 1)));
|
|
return texture(atlas, vec3(vec2(wx, wy) / span, l)).rgb;
|
|
}
|
|
|
|
void main() {
|
|
vec4 p = pc.cam;
|
|
float pitch = clamp(pc.params.x, 0.15, 1.45);
|
|
float fov = clamp(pc.params.y, 0.3, 1.4);
|
|
float world_w = pc.mapinfo.x * CELL;
|
|
float world_h = pc.mapinfo.y * CELL;
|
|
|
|
float cp = cos(pitch);
|
|
vec3 fwd = vec3(cp * sin(p.z), cp * cos(p.z), -sin(pitch));
|
|
vec3 right = normalize(cross(fwd, vec3(0, 0, 1)));
|
|
vec3 up = cross(right, fwd);
|
|
|
|
float target_z = world_height(p.x, p.y);
|
|
if (target_z < -1.0e8) target_z = 0.0;
|
|
float dist = p.w / sin(pitch);
|
|
vec3 cam = vec3(p.x, p.y, target_z + p.w) - fwd * dist;
|
|
|
|
float aspect = 1.0; // set by caller implicitly via square-ish UV; corrected below
|
|
vec2 ndc = vec2(in_uv.x * 2.0 - 1.0, 1.0 - in_uv.y * 2.0);
|
|
// aspect passed in misc.w
|
|
aspect = pc.misc.w;
|
|
float th = tan(fov * 0.5);
|
|
vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
|
|
|
|
if (dir.z >= -1e-4) {
|
|
out_color = vec4(sky_color(dir), 1.0);
|
|
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 < 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.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.10; t += dt;
|
|
}
|
|
if (!hit) { out_color = vec4(sky_color(dir), 1.0); return; }
|
|
|
|
float lo = prev, hi = hit_t;
|
|
for (int i = 0; i < 6; ++i) {
|
|
float mid = 0.5 * (lo + hi);
|
|
vec3 w = cam + dir * mid;
|
|
bool water = pc.params.w > 0.5 && w.z <= pc.params.z;
|
|
if (water || w.z <= world_height(w.x, w.y)) hi = mid; else lo = mid;
|
|
}
|
|
vec3 hitpos = cam + dir * hi;
|
|
|
|
vec3 sun = normalize(pc.sun.xyz);
|
|
float ambient = pc.sun.w;
|
|
|
|
if (pc.params.w > 0.5 && hitpos.z <= pc.params.z + 0.01) {
|
|
// Water: animated normal from a procedural wave, sky reflection + fresnel.
|
|
float time = pc.misc.x;
|
|
vec2 q = hitpos.xy * 0.015;
|
|
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
|
|
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
|
|
vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0));
|
|
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
|
|
vec3 deep = vec3(0.03, 0.16, 0.28);
|
|
vec3 refl = sky_color(reflect(dir, n));
|
|
float lam = max(0.0, dot(n, sun));
|
|
vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
|
|
water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint
|
|
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
|
water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
|
|
out_color = vec4(water, 1.0);
|
|
return;
|
|
}
|
|
|
|
// Terrain: read the per-cell blend record, sample the base/blend/three-way
|
|
// material layers continuously and ramp between them across the cell.
|
|
float wx = hitpos.x / CELL;
|
|
float wy = (world_h - hitpos.y) / CELL;
|
|
int cx = clamp(int(wx), 0, int(pc.mapinfo.x) - 1);
|
|
int cy = clamp(int(wy), 0, int(pc.mapinfo.y) - 1);
|
|
float fx = wx - floor(wx);
|
|
float fy = wy - floor(wy);
|
|
|
|
uvec4 record = uvec4(texelFetch(celldata, ivec2(cx, cy), 0) * 65535.0 + 0.5);
|
|
uint packed = record.w;
|
|
uint dir1 = packed & 0xFu;
|
|
uint flags1 = (packed >> 4u) & 0x3u;
|
|
uint dir2 = (packed >> 8u) & 0xFu;
|
|
uint flags2 = (packed >> 12u) & 0x3u;
|
|
|
|
vec2 fracUV = vec2(fx, fy);
|
|
vec3 c0 = sample_layer(record.x, wx, wy);
|
|
vec3 c1 = sample_layer(record.y, wx, wy);
|
|
vec3 c2 = sample_layer(record.z, wx, wy);
|
|
float f1 = blend_factor(dir1, flags1, fracUV);
|
|
float f2 = blend_factor(dir2, flags2, fracUV);
|
|
vec3 albedo = mix(mix(c0, c1, f1), c2, f2);
|
|
|
|
// Per-pixel normal from the heightfield.
|
|
float hl = world_height(hitpos.x - CELL, hitpos.y);
|
|
float hr = world_height(hitpos.x + CELL, hitpos.y);
|
|
float hd = world_height(hitpos.x, hitpos.y - CELL);
|
|
float hu = world_height(hitpos.x, hitpos.y + CELL);
|
|
vec3 n = normalize(vec3(hl - hr, hd - hu, 2.0 * CELL));
|
|
float lambert = max(0.0, dot(n, sun));
|
|
vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert);
|
|
// Distance haze toward the horizon so the map edge blends into the sky.
|
|
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
|
lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog);
|
|
out_color = vec4(lit, 1.0);
|
|
}
|