diff --git a/src/mesh.rs b/src/mesh.rs index bce712f..90de74e 100644 --- a/src/mesh.rs +++ b/src/mesh.rs @@ -9,6 +9,10 @@ pub struct Vertex { pub color: [f32; 3], pub normal: [f32; 3], pub leaf: f32, + /// Per-vertex ambient occlusion baked at mesh-build time, 0..1 + /// (0 = fully occluded crevice, 1 = open). Computed once on the CPU so + /// the fragment shader pays one multiply. + pub ao: f32, } impl Vertex { @@ -20,6 +24,7 @@ impl Vertex { 1 => Float32x3, 2 => Float32x3, 3 => Float32, + 4 => Float32, ], }; } @@ -29,9 +34,49 @@ pub struct ChunkMesh { pub indices: Vec, } -/// Greedy meshing: per face direction, build a 2D mask per slice and merge same-block -/// rectangles into one quad. Dramatically reduces triangle count on large flat regions -/// (terrain, big walls). +/// One mask cell — block type plus the four per-corner AO levels. +/// Cells with the same block but different AO can't be greedy-merged, since +/// they would otherwise share corner vertices that disagree on shading. +#[derive(Copy, Clone, PartialEq, Eq)] +struct MaskCell { + block: Block, + /// AO at the four corners in (min-u, min-v) (max-u, min-v) (max-u, max-v) (min-u, max-v) + /// order, each value 0..=3. + ao: [u8; 4], +} + +/// Classic Minecraft 4-corner AO. `face_plane` is the *air* block one step +/// past the face in the normal direction. `du` and `dv` are unit vectors +/// in the face plane pointing toward the corner of interest. Returns 0..=3 +/// (0 = darkest crevice, 3 = open). Special case: if both adjacent edge +/// blocks are solid the corner is fully dark regardless of the diagonal. +fn corner_ao(world: &World, face_plane: IVec3, du: IVec3, dv: IVec3) -> u8 { + let s1 = world.get_block(face_plane + du).solid() as u8; + let s2 = world.get_block(face_plane + dv).solid() as u8; + let c = world.get_block(face_plane + du + dv).solid() as u8; + if s1 == 1 && s2 == 1 { + 0 + } else { + 3 - (s1 + s2 + c) + } +} + +const AO_TABLE: [f32; 4] = [0.45, 0.65, 0.80, 1.00]; + +fn unit_axis(a: usize) -> IVec3 { + match a { + 0 => IVec3::X, + 1 => IVec3::Y, + 2 => IVec3::Z, + _ => unreachable!(), + } +} + +/// Greedy meshing with baked AO. For each face direction we build a 2-D +/// mask of `(block, ao[4])` cells, then merge contiguous cells that match +/// exactly in both block-type and AO tuple. The output mesh carries one AO +/// value per vertex; the fragment shader multiplies it into the lit color +/// so crevices darken naturally. pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { let mut vertices: Vec = Vec::with_capacity(2048); let mut indices: Vec = Vec::with_capacity(3072); @@ -55,7 +100,10 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { let size_v = dims[v_axis]; let n_arr = [normal.x as f32, normal.y as f32, normal.z as f32]; - let mut mask: Vec> = vec![None; (size_u * size_v) as usize]; + let a_unit = unit_axis(u_axis); + let b_unit = unit_axis(v_axis); + + let mut mask: Vec> = vec![None; (size_u * size_v) as usize]; for d in 0..size_a { for cell in mask.iter_mut() { @@ -83,9 +131,21 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { .get_block(IVec3::new(base_x + nx, ny, base_z + nz)) .solid() }; - if !neighbor_solid { - mask[(v * size_u + u) as usize] = Some(block); + if neighbor_solid { + continue; } + // World-space block position. + let block_world = IVec3::new(base_x + p[0], p[1], base_z + p[2]); + let face_plane = block_world + normal; + // 4 corner AO values, ordered (min-u min-v), (max-u min-v), + // (max-u max-v), (min-u max-v). + let ao = [ + corner_ao(world, face_plane, -a_unit, -b_unit), + corner_ao(world, face_plane, a_unit, -b_unit), + corner_ao(world, face_plane, a_unit, b_unit), + corner_ao(world, face_plane, -a_unit, b_unit), + ]; + mask[(v * size_u + u) as usize] = Some(MaskCell { block, ao }); } } @@ -93,17 +153,20 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { let mut u0 = 0; while u0 < size_u { let head = mask[(v0 * size_u + u0) as usize]; - if let Some(b) = head { + if let Some(cell) = head { + // Greedy extend in u as long as cells match exactly. let mut w = 1; while u0 + w < size_u - && mask[(v0 * size_u + u0 + w) as usize] == Some(b) + && mask[(v0 * size_u + u0 + w) as usize] == Some(cell) { w += 1; } + // Greedy extend in v as long as every cell in the + // candidate row matches the head's (block, ao). let mut h = 1; 'row: while v0 + h < size_v { for k in 0..w { - if mask[((v0 + h) * size_u + u0 + k) as usize] != Some(b) { + if mask[((v0 + h) * size_u + u0 + k) as usize] != Some(cell) { break 'row; } } @@ -123,25 +186,58 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { let c2 = to_world(u0 + w, v0 + h); let c3 = to_world(u0, v0 + h); - let color = b.face_color(face); - let leaf = if b == Block::Leaves { 1.0 } else { 0.0 }; + let color = cell.block.face_color(face); + let leaf = if cell.block == Block::Leaves { 1.0 } else { 0.0 }; + let ao_f = [ + AO_TABLE[cell.ao[0] as usize], + AO_TABLE[cell.ao[1] as usize], + AO_TABLE[cell.ao[2] as usize], + AO_TABLE[cell.ao[3] as usize], + ]; let base_idx = vertices.len() as u32; - for c in [c0, c1, c2, c3] { + let corners = [c0, c1, c2, c3]; + for i in 0..4 { vertices.push(Vertex { - pos: c, + pos: corners[i], color, normal: n_arr, leaf, + ao: ao_f[i], }); } + // Flip the diagonal when AO is "anisotropic" — i.e. + // when ao[0]+ao[2] < ao[1]+ao[3]. This stops the + // visible diagonal gradient artifact across quads + // where the four corners disagree. + let flip = ao_f[0] + ao_f[2] < ao_f[1] + ao_f[3]; if positive { + if flip { + indices.extend_from_slice(&[ + base_idx, + base_idx + 1, + base_idx + 3, + base_idx + 1, + base_idx + 2, + base_idx + 3, + ]); + } else { + indices.extend_from_slice(&[ + base_idx, + base_idx + 1, + base_idx + 2, + base_idx, + base_idx + 2, + base_idx + 3, + ]); + } + } else if flip { indices.extend_from_slice(&[ base_idx, - base_idx + 1, - base_idx + 2, - base_idx, - base_idx + 2, base_idx + 3, + base_idx + 1, + base_idx + 1, + base_idx + 3, + base_idx + 2, ]); } else { indices.extend_from_slice(&[ @@ -256,6 +352,61 @@ mod tests { ); } + #[test] + fn isolated_block_has_full_ao() { + // A single block in empty space has no occluders, so every vertex + // should report fully-open AO (1.0). + let world = single_chunk_world(|c| c.set(8, 4, 8, Block::Stone)); + let chunk = world.chunks.get(&IVec3::ZERO).unwrap(); + let mesh = build_chunk_mesh(&world, chunk); + for v in &mesh.vertices { + assert!((v.ao - 1.0).abs() < 1e-6, "isolated face vertex ao={} (expected 1.0)", v.ao); + } + } + + #[test] + fn neighboring_blocks_darken_shared_corner() { + // Two stones with a third sitting above the corner where their +Y + // faces meet — that corner must be darker than the corners far + // from the occluder. + // + // y+1: [occ at (5,5,4)] + // y: stone@(4,4,4) stone@(5,4,4) + // + // The corner at world (5, 5, 4) of stone(4,4,4)'s +Y face has an + // occluding block touching it from above on the +X side. + let world = single_chunk_world(|c| { + c.set(4, 4, 4, Block::Stone); + c.set(5, 4, 4, Block::Stone); + c.set(5, 5, 4, Block::Stone); // occluder above the right stone + }); + let chunk = world.chunks.get(&IVec3::ZERO).unwrap(); + let mesh = build_chunk_mesh(&world, chunk); + // The +Y face of stone(4,4,4) — find a vertex near (5,5,4)/(5,5,5) + // (the edge touching the occluder) and confirm its ao < 1.0. + let mut min_ao_near: f32 = 1.0; + let mut min_ao_far: f32 = 1.0; + for v in &mesh.vertices { + // restrict to top faces (normal.y > 0.5) + if v.normal[1] < 0.5 { continue; } + // is this vertex on stone(4,4,4) — within its quad bounds? + let x = v.pos[0]; + let z = v.pos[2]; + if x >= 4.0 - 0.01 && x <= 5.0 + 0.01 && z >= 4.0 - 0.01 && z <= 5.0 + 0.01 { + // vertices on the +X edge are close to the occluder + if (x - 5.0).abs() < 0.01 { + if v.ao < min_ao_near { min_ao_near = v.ao; } + } + // vertices on the -X edge are far from the occluder + if (x - 4.0).abs() < 0.01 { + if v.ao < min_ao_far { min_ao_far = v.ao; } + } + } + } + assert!(min_ao_near < 1.0, "corner adjacent to occluder must be darkened, was {}", min_ao_near); + assert!(min_ao_far > min_ao_near, "open corner must be brighter than the occluded one ({} vs {})", min_ao_far, min_ao_near); + } + #[test] fn empty_chunk_produces_no_geometry() { let world = single_chunk_world(|_| {}); diff --git a/src/post.wgsl b/src/post.wgsl new file mode 100644 index 0000000..fd792a8 --- /dev/null +++ b/src/post.wgsl @@ -0,0 +1,31 @@ +// Step 1 of the post-process rebuild: minimal pass-through. Samples the +// offscreen scene_color and writes it straight to the surface. Effects +// (FXAA, sun shafts, tonemap) layer on top of this in later steps. + +@group(0) @binding(0) var scene_color_tex: texture_2d; +@group(0) @binding(1) var scene_color_sampler: sampler; + +struct PostOut { + @builtin(position) clip: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_post(@builtin(vertex_index) idx: u32) -> PostOut { + var corners = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + let p = corners[idx]; + var out: PostOut; + out.clip = vec4(p, 0.0, 1.0); + // Texture origin is top-left; flip Y so screen coords map to texel coords. + out.uv = vec2(p.x * 0.5 + 0.5, p.y * -0.5 + 0.5); + return out; +} + +@fragment +fn fs_post(in: PostOut) -> @location(0) vec4 { + return textureSample(scene_color_tex, scene_color_sampler, in.uv); +} diff --git a/src/shader.wgsl b/src/shader.wgsl index 20a8dbd..1334b22 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -2,33 +2,176 @@ struct Camera { view_proj: mat4x4, inv_view_proj: mat4x4, eye: vec4, + /// .x = scene time in seconds (drives day/night cycle + leaf sway) misc: vec4, }; @group(0) @binding(0) var camera: Camera; -const SUN_DIR: vec3 = vec3(0.42, 0.82, 0.39); -const SKY_HORIZON: vec3 = vec3(0.78, 0.88, 0.96); -const SKY_ZENITH: vec3 = vec3(0.30, 0.55, 0.88); -const SUN_COLOR: vec3 = vec3(1.0, 0.95, 0.85); +// ---------------- Time-of-day primitives ---------------- +// +// One in-game day takes DAY_PERIOD seconds. The sun sweeps an east-to-west +// arc (cos/sin on the same plane) with a small constant tilt on Z so it +// isn't dead-flat. Game starts at noon (offset = 0.25 cycles). + +const DAY_PERIOD: f32 = 300.0; +const SUN_OFFSET: f32 = 0.25; + +fn sun_direction(t: f32) -> vec3 { + let a = (t / DAY_PERIOD + SUN_OFFSET) * 6.28318530718; + return normalize(vec3(cos(a), sin(a), 0.25)); +} + +// Smooth 0..1 going from -0.05 (sun barely under horizon, blue hour) up +// to 0.20 (clearly above the horizon, full daylight). +fn day_strength(sun: vec3) -> f32 { + return smoothstep(-0.05, 0.20, sun.y); +} + +// Twilight peaks while the sun is near the horizon — sunrise + sunset. +fn twilight_amount(sun: vec3) -> f32 { + let above = smoothstep(-0.10, 0.05, sun.y); + let high = smoothstep(0.05, 0.30, sun.y); + return above - high; +} + +fn sun_tint(sun: vec3) -> vec3 { + let twi = twilight_amount(sun); + return mix(vec3(1.00, 0.95, 0.85), vec3(1.00, 0.55, 0.30), twi); +} + +// ---------------- Cheap 2D fbm for clouds ---------------- + +fn hash21(p: vec2) -> f32 { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +fn noise2(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let u = f * f * (3.0 - 2.0 * f); + let a = hash21(i); + let b = hash21(i + vec2(1.0, 0.0)); + let c = hash21(i + vec2(0.0, 1.0)); + let d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +fn fbm2(p_in: vec2) -> f32 { + var p = p_in; + var v = 0.0; + var amp = 0.5; + for (var i = 0; i < 4; i = i + 1) { + v = v + amp * noise2(p); + p = p * 2.07; + amp = amp * 0.5; + } + return v; +} + +// Just the horizon→zenith gradient — no clouds, no sun, no stars. Used by +// the terrain shader to compute hemisphere ambient: each fragment samples +// the dome in its surface-normal direction so vertical faces inherit the +// bright daytime horizon instead of a flat dim ambient. +fn sky_dome(dir: vec3, sun: vec3) -> vec3 { + let day = day_strength(sun); + let twi = twilight_amount(sun); + let zenith_day = vec3(0.30, 0.55, 0.88); + let zenith_night = vec3(0.02, 0.03, 0.10); + let horizon_day = vec3(0.82, 0.92, 0.99); + let horizon_twi = vec3(1.00, 0.55, 0.28); + let horizon_night = vec3(0.03, 0.04, 0.10); + let zenith = mix(zenith_night, zenith_day, day); + let horizon = mix(mix(horizon_night, horizon_day, day), horizon_twi, twi); + let up = clamp(dir.y, -1.0, 1.0); + let gradient_t = pow(max(up, 0.0), 0.55); + return mix(horizon, zenith, gradient_t); +} + +// Cheap "stars" — high-frequency hash on view direction, threshold to +// keep only ~0.2% of cells lit. +fn star_field(dir: vec3) -> f32 { + if (dir.y <= 0.0) { return 0.0; } + let cell = floor(dir * 220.0); + let h = fract(sin(dot(cell, vec3(12.9898, 78.233, 37.719))) * 43758.5453); + return step(0.997, h); +} + +// ---------------- Sky ---------------- +// +// `dir` is the *view* direction from camera into the scene (unit vector). +// Composes a horizon→zenith gradient that re-tones with sun height, +// twinklers + cloud streaks + sun + moon discs. fn sky_color(dir: vec3) -> vec3 { + let t = camera.misc.x; + let sun = sun_direction(t); + let day = day_strength(sun); + let twi = twilight_amount(sun); + + let zenith_day = vec3(0.30, 0.55, 0.88); + let zenith_night = vec3(0.02, 0.03, 0.10); + let horizon_day = vec3(0.78, 0.88, 0.96); + let horizon_twi = vec3(1.00, 0.55, 0.28); + let horizon_night = vec3(0.03, 0.04, 0.10); + + let zenith = mix(zenith_night, zenith_day, day); + let horizon = mix(mix(horizon_night, horizon_day, day), horizon_twi, twi); + let up = clamp(dir.y, -1.0, 1.0); - let t = pow(max(up, 0.0), 0.55); - let base = mix(SKY_HORIZON, SKY_ZENITH, t); - // Slight darken below horizon (mostly never seen, but soft). + let gradient_t = pow(max(up, 0.0), 0.55); + var sky = mix(horizon, zenith, gradient_t); + + // Below-horizon slight darken so the world below the player still feels grounded. let below = step(up, 0.0) * 0.2; - let s = max(dot(normalize(dir), SUN_DIR), 0.0); - let disc = pow(s, 800.0) * 1.4; - let halo = pow(s, 6.0) * 0.18; - return base * (1.0 - below) + SUN_COLOR * (disc + halo); + sky = sky * (1.0 - below); + + // Stars: fade in as day strength drops. Slight twinkle via time-based jitter. + let night_amt = clamp(1.0 - day, 0.0, 1.0); + if (night_amt > 0.05) { + let st = star_field(dir); + let twinkle = 0.7 + 0.3 * sin(t * 6.0 + dir.x * 100.0 + dir.z * 130.0); + sky = sky + vec3(st * night_amt * twinkle); + } + + // Cloud layer — fbm scrolled across an imaginary plane high above. Only + // visible looking upward (dir.y > 0). Cheap: 4 octaves of value noise. + if (dir.y > 0.05) { + let proj = dir.xz / dir.y; + let scroll = vec2(t * 0.004, t * 0.0015); + let n = fbm2(proj * 0.50 + scroll); + let mask = smoothstep(0.50, 0.78, n); + let cloud_lit = mix(vec3(0.30, 0.30, 0.35), vec3(1.00, 0.97, 0.92), day); + let cloud_twi = vec3(1.00, 0.60, 0.45); + let cloud_col = mix(cloud_lit, cloud_twi, twi * 0.7); + sky = mix(sky, cloud_col, mask * (0.55 + 0.25 * day)); + } + + // Sun disc + halo. Disc only visible in daytime (no sun glow underground). + let sun_col = sun_tint(sun); + let cos_s = max(dot(dir, sun), 0.0); + let disc = pow(cos_s, 800.0) * 1.5 * smoothstep(-0.05, 0.05, sun.y); + let halo = pow(cos_s, 5.0) * 0.20 * day; + sky = sky + sun_col * (disc + halo); + + // Moon disc — opposite the sun, faint white. Only at night. + let moon = -sun; + let cos_m = max(dot(dir, moon), 0.0); + let moon_disc = pow(cos_m, 700.0) * 0.9; + let moon_halo = pow(cos_m, 24.0) * 0.06; + sky = sky + vec3(0.86, 0.89, 0.96) * (moon_disc + moon_halo) * night_amt; + + return sky; } +// ---------------- Terrain ---------------- + struct VsIn { @location(0) pos: vec3, @location(1) color: vec3, @location(2) normal: vec3, @location(3) leaf: f32, + @location(4) ao: f32, }; struct VsOut { @@ -37,6 +180,7 @@ struct VsOut { @location(1) color: vec3, @location(2) normal: vec3, @location(3) leaf: f32, + @location(4) ao: f32, }; @vertex @@ -57,17 +201,46 @@ fn vs_main(in: VsIn) -> VsOut { out.color = in.color; out.normal = in.normal; out.leaf = in.leaf; + out.ao = in.ao; return out; } @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { - let n = normalize(in.normal); - let ndl = max(dot(n, SUN_DIR), 0.0); - let ambient = 0.40; - var lit = in.color * (ambient + (1.0 - ambient) * ndl); + let t = camera.misc.x; + let sun = sun_direction(t); + let day = day_strength(sun); + let sun_col = sun_tint(sun); - // Cheap procedural noise for leaves so the canopy doesn't look uniform. + let n = normalize(in.normal); + let ndl = max(dot(n, sun), 0.0); + let sun_visible = smoothstep(-0.05, 0.10, sun.y); + let sun_term = ndl * sun_visible; + + // Hemisphere ambient — *sample* the sky dome in the normal direction + // instead of lerping two constants. A vertical face (n.y ≈ 0) picks up + // the bright horizon, a top face (n.y ≈ 1) the (darker) zenith, a + // bottom face the earth-bounce. This is the cheap analogue of an + // integrated environment light and is what makes daytime sides not + // look like night. + let sky_in_normal = sky_dome(n, sun); + let earth_down_day = vec3(0.20, 0.18, 0.14); + let earth_down_night = vec3(0.03, 0.03, 0.04); + let earth_down = mix(earth_down_night, earth_down_day, day); + let face_up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + let ambient_col = mix(earth_down, sky_in_normal, face_up); + // Higher strength than before — outdoor diffuse skylight is roughly + // 10–20% of direct sun in reality. The old 0.45 cap was making sides + // read as if it were dusk during the day. + let ambient_strength = mix(0.25, 0.85, day); + + let lighting = ambient_col * ambient_strength + sun_col * sun_term; + var lit = in.color * lighting; + + // Bake-time per-vertex ambient occlusion. + lit = lit * in.ao; + + // Per-pixel value noise on leaves so the canopy doesn't look uniform. if (in.leaf > 0.5) { let n2 = fract(sin(dot(floor(in.world_pos * 1.3), vec3(12.9898, 78.233, 37.719))) * 43758.5453); lit = lit * (0.88 + n2 * 0.18); @@ -76,12 +249,18 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let to_eye = camera.eye.xyz - in.world_pos; let dist = length(to_eye); let view_dir = -to_eye / max(dist, 0.0001); - let sky = sky_color(-view_dir); let fog_start = 90.0; let fog_end = 320.0; let fog_t = clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0); - let color = mix(lit, sky, fog_t); + var color = lit; + if (fog_t > 0.001) { + // Only pay for the full sky lookup if the fragment is actually + // fogged enough to read it. Saves the cloud/fbm cost on near + // geometry. + let sky = sky_color(-view_dir); + color = mix(lit, sky, fog_t); + } return vec4(color, 1.0); } @@ -126,3 +305,4 @@ fn vs_outline(@location(0) pos: vec3) -> @builtin(position) vec4 { fn fs_outline() -> @location(0) vec4 { return vec4(0.05, 0.05, 0.07, 1.0); } + diff --git a/src/state.rs b/src/state.rs index 74b8f91..993ed77 100644 --- a/src/state.rs +++ b/src/state.rs @@ -108,6 +108,10 @@ struct Settings { fov_deg: f32, render_dist: f32, paused: bool, + /// Multiplier on real time used to drive the day/night cycle. 0 = frozen, + /// 1 = realtime (one in-game day every 5 minutes per shader constant), + /// up to 8x for fast-forward playtesting. + time_scale: f32, } impl Default for Settings { @@ -117,6 +121,7 @@ impl Default for Settings { fov_deg: 70.0, render_dist: 240.0, paused: false, + time_scale: 1.0, } } } @@ -249,6 +254,10 @@ mod wasm_api { pub fn set_render_distance(blocks: f32) { super::SETTINGS.with(|x| x.borrow_mut().render_dist = blocks.clamp(32.0, 1200.0)); } + #[wasm_bindgen] + pub fn set_time_scale(s: f32) { + super::SETTINGS.with(|x| x.borrow_mut().time_scale = s.clamp(0.0, 8.0)); + } /// Clears all bridge input (move/look/buttons) — called on init, /// pause, and visibility-change so we never resume with stale state. #[wasm_bindgen] @@ -288,6 +297,16 @@ pub struct Renderer { remote_vb: wgpu::Buffer, remote_ib: wgpu::Buffer, remote_index_count: u32, + + // ---- Post processing (Step 1: pass-through scene → surface) ---- + /// Offscreen color target the world is rendered into. Same format as + /// the surface so the post pipeline can write to either interchangeably. + scene_color: wgpu::TextureView, + scene_color_format: wgpu::TextureFormat, + post_sampler: wgpu::Sampler, + post_bgl: wgpu::BindGroupLayout, + post_bind_group: wgpu::BindGroup, + post_pipeline: wgpu::RenderPipeline, } const MAX_REMOTE_PLAYERS: u64 = 32; @@ -626,6 +645,89 @@ impl Renderer { cache: None, }); + // ---------- Post pipeline (Step 1: pass-through) ---------- + let scene_color_format = config.format; + let scene_color = create_scene_color_view(&device, width, height, scene_color_format); + + let post_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("post sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + let post_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("post bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let post_bind_group = create_post_bg(&device, &post_bgl, &scene_color, &post_sampler); + + let post_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("post shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("post.wgsl").into()), + }); + + let post_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("post pl"), + bind_group_layouts: &[&post_bgl], + push_constant_ranges: &[], + }); + + let post_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("post pipeline"), + layout: Some(&post_pl), + vertex: wgpu::VertexState { + module: &post_shader, + entry_point: Some("vs_post"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &post_shader, + entry_point: Some("fs_post"), + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + Self { surface, device, @@ -645,6 +747,12 @@ impl Renderer { remote_vb, remote_ib, remote_index_count: 0, + scene_color, + scene_color_format, + post_sampler, + post_bgl, + post_bind_group, + post_pipeline, } } @@ -726,6 +834,10 @@ impl Renderer { self.config.height = height; self.surface.configure(&self.device, &self.config); self.depth_view = create_depth_view(&self.device, width, height); + self.scene_color = + create_scene_color_view(&self.device, width, height, self.scene_color_format); + self.post_bind_group = + create_post_bg(&self.device, &self.post_bgl, &self.scene_color, &self.post_sampler); } pub fn rebuild_chunk(&mut self, coord: IVec3, world: &World) { @@ -780,17 +892,19 @@ impl Renderer { pub fn render(&self) -> Result<(), wgpu::SurfaceError> { let frame = self.surface.get_current_texture()?; - let view = frame + let surface_view = frame .texture .create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("enc") }); + + // ---- Scene pass: render the world into the offscreen scene_color. ---- { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("main pass"), + label: Some("scene pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, + view: &self.scene_color, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { @@ -841,6 +955,28 @@ impl Renderer { pass.draw(0..OUTLINE_VERT_COUNT as u32, 0..1); } } + + // ---- Post pass: copy scene_color to the surface (effects later). ---- + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("post pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &surface_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&self.post_pipeline); + pass.set_bind_group(0, &self.post_bind_group, &[]); + pass.draw(0..3, 0..1); + } + self.queue.submit(Some(encoder.finish())); frame.present(); Ok(()) @@ -883,6 +1019,11 @@ pub struct App { max_y_since_ground: f32, last_net_send: f32, was_connected: bool, + /// Accumulated *scaled* time. Real-time `dt` is multiplied by + /// `settings.time_scale` each tick before being added, so the shader's + /// day/night cycle slows / freezes / fast-forwards according to the + /// player's setting. + shader_time: f32, } struct FrameClock { @@ -1267,11 +1408,15 @@ impl App { None => 0.016, }; self.last_frame = Some(FrameClock::now()); - let time = self + let real_time = self .start_clock .as_ref() .map(|c| c.elapsed().as_secs_f32()) .unwrap_or(0.0); + let scale = SETTINGS.with(|s| s.borrow().time_scale); + self.shader_time += dt * scale; + let time = real_time; + let shader_time = self.shader_time; let settings = SETTINGS.with(|s| *s.borrow()); @@ -1311,7 +1456,7 @@ impl App { r.set_outline(None); r.set_visible(visible); r.set_remote_players(&remotes); - r.upload_camera(camera, time); + r.upload_camera(camera, shader_time); let _ = r.render(); } } @@ -1733,6 +1878,54 @@ pub fn fall_damage(distance: f32) -> u8 { } } +/// Create the offscreen color texture + view for the world render. Same +/// format as the surface so its pixels can be sampled and written back to +/// the surface without any conversion cost. +fn create_scene_color_view( + device: &wgpu::Device, + w: u32, + h: u32, + format: wgpu::TextureFormat, +) -> wgpu::TextureView { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("scene color"), + size: wgpu::Extent3d { + width: w.max(1), + height: h.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + tex.create_view(&wgpu::TextureViewDescriptor::default()) +} + +fn create_post_bg( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + scene: &wgpu::TextureView, + sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("post bg"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(scene), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) +} + fn find_safe_spawn(world: &World) -> Vec3 { use crate::world::natural_surface_y; let (x, z) = (0_i32, 0_i32); @@ -1842,6 +2035,7 @@ pub fn emit_oriented_box( color, normal: n_world, leaf: 0.0, + ao: 1.0, }); } indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]); diff --git a/web/index.html b/web/index.html index c852b9e..62c9b20 100644 --- a/web/index.html +++ b/web/index.html @@ -616,6 +616,11 @@ +