Render + UI polish since pre-alpha-0.0.1
- Greedy meshing now bakes per-vertex AO with 4-corner sampling and an anisotropic-diagonal split when corner AO disagrees. - WGSL: extracted sky_dome() for hemisphere ambient sampling so vertical faces match the sun-side sky tint at day; ambient_strength mixed by day strength instead of a flat constant. - Step-1 post pipeline: render scene into an offscreen color texture, pass-through to the surface. Foundation for FXAA/shafts that will follow. - Input bug: merge_held() now recomputes per tick from sticky keyboard + live touch bridge, so releasing the joystick actually stops the player (previous OR-into-self bug ate playtests). - Touch UI hit-zones reordered (menu/hotbar above the joystick z-index); hotbar widened to 10 slots with tap-to-select on mobile. - find_safe_spawn anchors on natural_surface_y so spawn is deterministic from noise — towers built at spawn no longer climb the spawn point. - move_axis is sub-stepped (0.45-block max) so high-velocity falls can't teleport the player inside terrain.
This commit is contained in:
parent
3a4ae970b2
commit
b52c1927cf
6 changed files with 609 additions and 41 deletions
185
src/mesh.rs
185
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<u32>,
|
||||
}
|
||||
|
||||
/// 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<Vertex> = Vec::with_capacity(2048);
|
||||
let mut indices: Vec<u32> = 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<Option<Block>> = 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<Option<MaskCell>> = 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(|_| {});
|
||||
|
|
|
|||
31
src/post.wgsl
Normal file
31
src/post.wgsl
Normal file
|
|
@ -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<f32>;
|
||||
@group(0) @binding(1) var scene_color_sampler: sampler;
|
||||
|
||||
struct PostOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_post(@builtin(vertex_index) idx: u32) -> PostOut {
|
||||
var corners = array<vec2<f32>, 3>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>( 3.0, -1.0),
|
||||
vec2<f32>(-1.0, 3.0),
|
||||
);
|
||||
let p = corners[idx];
|
||||
var out: PostOut;
|
||||
out.clip = vec4<f32>(p, 0.0, 1.0);
|
||||
// Texture origin is top-left; flip Y so screen coords map to texel coords.
|
||||
out.uv = vec2<f32>(p.x * 0.5 + 0.5, p.y * -0.5 + 0.5);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_post(in: PostOut) -> @location(0) vec4<f32> {
|
||||
return textureSample(scene_color_tex, scene_color_sampler, in.uv);
|
||||
}
|
||||
216
src/shader.wgsl
216
src/shader.wgsl
|
|
@ -2,33 +2,176 @@ struct Camera {
|
|||
view_proj: mat4x4<f32>,
|
||||
inv_view_proj: mat4x4<f32>,
|
||||
eye: vec4<f32>,
|
||||
/// .x = scene time in seconds (drives day/night cycle + leaf sway)
|
||||
misc: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> camera: Camera;
|
||||
|
||||
const SUN_DIR: vec3<f32> = vec3<f32>(0.42, 0.82, 0.39);
|
||||
const SKY_HORIZON: vec3<f32> = vec3<f32>(0.78, 0.88, 0.96);
|
||||
const SKY_ZENITH: vec3<f32> = vec3<f32>(0.30, 0.55, 0.88);
|
||||
const SUN_COLOR: vec3<f32> = vec3<f32>(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<f32> {
|
||||
let a = (t / DAY_PERIOD + SUN_OFFSET) * 6.28318530718;
|
||||
return normalize(vec3<f32>(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>) -> 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>) -> 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<f32>) -> vec3<f32> {
|
||||
let twi = twilight_amount(sun);
|
||||
return mix(vec3<f32>(1.00, 0.95, 0.85), vec3<f32>(1.00, 0.55, 0.30), twi);
|
||||
}
|
||||
|
||||
// ---------------- Cheap 2D fbm for clouds ----------------
|
||||
|
||||
fn hash21(p: vec2<f32>) -> f32 {
|
||||
return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
fn noise2(p: vec2<f32>) -> 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<f32>(1.0, 0.0));
|
||||
let c = hash21(i + vec2<f32>(0.0, 1.0));
|
||||
let d = hash21(i + vec2<f32>(1.0, 1.0));
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
fn fbm2(p_in: vec2<f32>) -> 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<f32>, sun: vec3<f32>) -> vec3<f32> {
|
||||
let day = day_strength(sun);
|
||||
let twi = twilight_amount(sun);
|
||||
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
|
||||
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
|
||||
let horizon_day = vec3<f32>(0.82, 0.92, 0.99);
|
||||
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
|
||||
let horizon_night = vec3<f32>(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>) -> f32 {
|
||||
if (dir.y <= 0.0) { return 0.0; }
|
||||
let cell = floor(dir * 220.0);
|
||||
let h = fract(sin(dot(cell, vec3<f32>(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<f32>) -> vec3<f32> {
|
||||
let t = camera.misc.x;
|
||||
let sun = sun_direction(t);
|
||||
let day = day_strength(sun);
|
||||
let twi = twilight_amount(sun);
|
||||
|
||||
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
|
||||
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
|
||||
let horizon_day = vec3<f32>(0.78, 0.88, 0.96);
|
||||
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
|
||||
let horizon_night = vec3<f32>(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<f32>(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<f32>(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<f32>(0.30, 0.30, 0.35), vec3<f32>(1.00, 0.97, 0.92), day);
|
||||
let cloud_twi = vec3<f32>(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<f32>(0.86, 0.89, 0.96) * (moon_disc + moon_halo) * night_amt;
|
||||
|
||||
return sky;
|
||||
}
|
||||
|
||||
// ---------------- Terrain ----------------
|
||||
|
||||
struct VsIn {
|
||||
@location(0) pos: vec3<f32>,
|
||||
@location(1) color: vec3<f32>,
|
||||
@location(2) normal: vec3<f32>,
|
||||
@location(3) leaf: f32,
|
||||
@location(4) ao: f32,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
|
|
@ -37,6 +180,7 @@ struct VsOut {
|
|||
@location(1) color: vec3<f32>,
|
||||
@location(2) normal: vec3<f32>,
|
||||
@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<f32> {
|
||||
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<f32>(0.20, 0.18, 0.14);
|
||||
let earth_down_night = vec3<f32>(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<f32>(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<f32> {
|
|||
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<f32>(color, 1.0);
|
||||
}
|
||||
|
|
@ -126,3 +305,4 @@ fn vs_outline(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
|
|||
fn fs_outline() -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(0.05, 0.05, 0.07, 1.0);
|
||||
}
|
||||
|
||||
|
|
|
|||
204
src/state.rs
204
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]);
|
||||
|
|
|
|||
|
|
@ -616,6 +616,11 @@
|
|||
<input id="set-dist" type="range" min="64" max="800" step="16" />
|
||||
<span class="value" id="set-dist-val"></span>
|
||||
</div>
|
||||
<div class="menu-row">
|
||||
<label for="set-tscale">Time of day speed</label>
|
||||
<input id="set-tscale" type="range" min="0" max="8" step="0.25" />
|
||||
<span class="value" id="set-tscale-val"></span>
|
||||
</div>
|
||||
<div class="menu-actions">
|
||||
<button id="menu-resume">RESUME</button>
|
||||
<button id="menu-respawn" class="secondary">Respawn</button>
|
||||
|
|
|
|||
|
|
@ -90,12 +90,15 @@ function setupMenu() {
|
|||
const fovVal = document.getElementById("set-fov-val");
|
||||
const dist = document.getElementById("set-dist");
|
||||
const distVal = document.getElementById("set-dist-val");
|
||||
const tscale = document.getElementById("set-tscale");
|
||||
const tscaleVal = document.getElementById("set-tscale-val");
|
||||
const name = document.getElementById("set-name");
|
||||
|
||||
const saved = JSON.parse(localStorage.getItem("voxel-settings") || "{}");
|
||||
sens.value = saved.sens ?? 0.005;
|
||||
fov.value = saved.fov ?? 70;
|
||||
dist.value = saved.dist ?? 240;
|
||||
tscale.value = saved.tscale ?? 1.0;
|
||||
name.value = localStorage.getItem("voxel-name") || "";
|
||||
const topName = document.getElementById("player-name");
|
||||
if (topName) topName.value = name.value;
|
||||
|
|
@ -104,17 +107,21 @@ function setupMenu() {
|
|||
const sv = parseFloat(sens.value);
|
||||
const fv = parseFloat(fov.value);
|
||||
const dv = parseFloat(dist.value);
|
||||
const tv = parseFloat(tscale.value);
|
||||
wasm.set_mouse_sens(sv);
|
||||
wasm.set_fov(fv);
|
||||
wasm.set_render_distance(dv);
|
||||
wasm.set_time_scale(tv);
|
||||
sensVal.textContent = sv.toFixed(4);
|
||||
fovVal.textContent = fv + "°";
|
||||
distVal.textContent = dv + " bl";
|
||||
localStorage.setItem("voxel-settings", JSON.stringify({ sens: sv, fov: fv, dist: dv }));
|
||||
tscaleVal.textContent = tv === 0 ? "frozen" : (tv.toFixed(2) + "×");
|
||||
localStorage.setItem("voxel-settings", JSON.stringify({ sens: sv, fov: fv, dist: dv, tscale: tv }));
|
||||
};
|
||||
sens.addEventListener("input", apply);
|
||||
fov.addEventListener("input", apply);
|
||||
dist.addEventListener("input", apply);
|
||||
tscale.addEventListener("input", apply);
|
||||
apply();
|
||||
|
||||
const pushName = () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue