Compare commits
5 commits
511798b6eb
...
f1e007dd63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e007dd63 | ||
|
|
dccb06dddf | ||
|
|
0dca49f475 | ||
|
|
bd6b3fadb0 | ||
|
|
94585b1ab2 |
23 changed files with 1577 additions and 148 deletions
54
src/app.rs
54
src/app.rs
|
|
@ -17,7 +17,7 @@
|
|||
//! 11. Block interaction (raycast → break/place) → maybe broadcast edit
|
||||
//! 12. Apply damage; periodic state broadcast
|
||||
//! 13. Render frame
|
||||
use crate::bridges::{self, RemotePlayer, Settings};
|
||||
use crate::bridges::{self, RemotePlayer, Settings, Telemetry, TestCommand};
|
||||
use crate::camera::{Camera, InputState, KbHeld};
|
||||
use crate::net::{parse_inbox, NetEvent};
|
||||
use crate::proto::{ClientMsg, EditRec};
|
||||
|
|
@ -398,8 +398,59 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply queued declarative-test commands. Called at the very top
|
||||
/// of tick so a scenario's "set time / teleport / look at" land
|
||||
/// before physics integrates anything this frame.
|
||||
fn drain_test_commands(&mut self) {
|
||||
for cmd in bridges::drain_test_commands() {
|
||||
match cmd {
|
||||
TestCommand::SetSceneTime(t) => {
|
||||
self.shader_time = t;
|
||||
}
|
||||
TestCommand::Teleport(p) => {
|
||||
self.body.feet = p;
|
||||
self.body.velocity = Vec3::ZERO;
|
||||
self.body.on_ground = false;
|
||||
self.body.max_y_since_ground = p.y;
|
||||
if let Some(cam) = self.camera.borrow_mut().as_mut() {
|
||||
cam.position = Vec3::new(p.x, p.y + EYE_HEIGHT, p.z);
|
||||
}
|
||||
}
|
||||
TestCommand::LookAt(yaw, pitch) => {
|
||||
if let Some(cam) = self.camera.borrow_mut().as_mut() {
|
||||
cam.yaw = yaw;
|
||||
cam.pitch = pitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish telemetry for the test harness to read back.
|
||||
fn publish_telemetry(&self) {
|
||||
let (yaw, pitch) = self
|
||||
.camera
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|c| (c.yaw, c.pitch))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
bridges::publish_telemetry(Telemetry {
|
||||
scene_time: self.shader_time,
|
||||
pos_x: self.body.feet.x,
|
||||
pos_y: self.body.feet.y,
|
||||
pos_z: self.body.feet.z,
|
||||
yaw,
|
||||
pitch,
|
||||
hp: self.body.hp,
|
||||
alive: self.body.alive,
|
||||
});
|
||||
}
|
||||
|
||||
/// One frame. See module doc-comment for the pipeline shape.
|
||||
fn tick(&mut self) {
|
||||
// Apply any test-harness commands before integrating physics.
|
||||
self.drain_test_commands();
|
||||
|
||||
let dt = match self.last_frame.as_ref() {
|
||||
Some(c) => c.elapsed().as_secs_f32().min(0.1),
|
||||
None => 0.016,
|
||||
|
|
@ -600,6 +651,7 @@ impl App {
|
|||
drop(world_borrow);
|
||||
drop(camera_borrow);
|
||||
self.render_frame(settings, outline);
|
||||
self.publish_telemetry();
|
||||
let _ = WORLD_RADIUS;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,38 @@ use glam::Vec3;
|
|||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------- Test harness ----------------
|
||||
//
|
||||
// Lightweight declarative-test surface: scenarios poke commands in
|
||||
// from JS (set scene time, teleport, look at angle) and read back
|
||||
// telemetry (current time, position, yaw/pitch) to verify what the
|
||||
// game thinks is true. App::tick drains the command queue at the
|
||||
// start of each frame and publishes telemetry at the end.
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum TestCommand {
|
||||
/// Override the shader_time directly (in seconds). Useful for
|
||||
/// stepping the day/night cycle to a specific phase for visual
|
||||
/// regression screenshots.
|
||||
SetSceneTime(f32),
|
||||
/// Teleport the player feet to this world position.
|
||||
Teleport(Vec3),
|
||||
/// Set camera yaw + pitch (radians).
|
||||
LookAt(f32, f32),
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
pub struct Telemetry {
|
||||
pub scene_time: f32,
|
||||
pub pos_x: f32,
|
||||
pub pos_y: f32,
|
||||
pub pos_z: f32,
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub hp: u8,
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
// ---------------- Data types stored in the bridges ----------------
|
||||
|
||||
/// User-tunable settings, mirrored from the JS settings menu.
|
||||
|
|
@ -79,6 +111,9 @@ thread_local! {
|
|||
});
|
||||
static NET_BRIDGE: RefCell<NetBridge> = RefCell::new(NetBridge::default());
|
||||
static SETTINGS: RefCell<Settings> = RefCell::new(Settings::default());
|
||||
// Test-harness storage.
|
||||
static TEST_COMMANDS: RefCell<Vec<TestCommand>> = RefCell::new(Vec::new());
|
||||
static TELEMETRY: RefCell<Telemetry> = RefCell::new(Telemetry::default());
|
||||
}
|
||||
|
||||
// ---------------- Public typed accessors ----------------
|
||||
|
|
@ -183,6 +218,16 @@ pub fn snapshot_remote_players() -> Vec<RemotePlayer> {
|
|||
NET_BRIDGE.with(|n| n.borrow().remote_players.values().cloned().collect())
|
||||
}
|
||||
|
||||
/// Drain queued test commands (called by App::tick at frame start).
|
||||
pub fn drain_test_commands() -> Vec<TestCommand> {
|
||||
TEST_COMMANDS.with(|q| std::mem::take(&mut *q.borrow_mut()))
|
||||
}
|
||||
|
||||
/// Publish current frame telemetry (called by App::tick at frame end).
|
||||
pub fn publish_telemetry(t: Telemetry) {
|
||||
TELEMETRY.with(|x| *x.borrow_mut() = t);
|
||||
}
|
||||
|
||||
// ---------------- wasm-bindgen JS interface ----------------
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
@ -295,4 +340,44 @@ mod wasm_api {
|
|||
pub fn reset_input() {
|
||||
super::clear_touch_inputs();
|
||||
}
|
||||
|
||||
// ----- Test harness exports -----
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn set_scene_time(t: f32) {
|
||||
super::TEST_COMMANDS.with(|q| {
|
||||
q.borrow_mut().push(super::TestCommand::SetSceneTime(t));
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn teleport(x: f32, y: f32, z: f32) {
|
||||
super::TEST_COMMANDS.with(|q| {
|
||||
q.borrow_mut()
|
||||
.push(super::TestCommand::Teleport(super::Vec3::new(x, y, z)));
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn look_at(yaw: f32, pitch: f32) {
|
||||
super::TEST_COMMANDS.with(|q| {
|
||||
q.borrow_mut().push(super::TestCommand::LookAt(yaw, pitch));
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn get_scene_time() -> f32 {
|
||||
super::TELEMETRY.with(|x| x.borrow().scene_time)
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn get_position() -> Vec<f32> {
|
||||
super::TELEMETRY.with(|x| {
|
||||
let t = x.borrow();
|
||||
vec![t.pos_x, t.pos_y, t.pos_z]
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn get_camera_angles() -> Vec<f32> {
|
||||
super::TELEMETRY.with(|x| {
|
||||
let t = x.borrow();
|
||||
vec![t.yaw, t.pitch]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
src/mask.wgsl
Normal file
67
src/mask.wgsl
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Sun-cone sky mask. Generates the input to the radial-blur shafts
|
||||
// pass: white where (a) this pixel is sky (scene_color.a == 0) AND
|
||||
// (b) the view direction is close to the sun direction; black
|
||||
// elsewhere. Runs at ¼ resolution to save fillrate.
|
||||
//
|
||||
// Sun direction is derived from scene time via the injected DAY_PERIOD
|
||||
// / SUN_OFFSET constants — same source of truth as sim::lighting and
|
||||
// the terrain shader, so the visual rays line up with the mechanical
|
||||
// sun direction by construction.
|
||||
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
inv_view_proj: mat4x4<f32>,
|
||||
eye: vec4<f32>,
|
||||
frame: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> camera: Camera;
|
||||
@group(1) @binding(0) var scene_tex: texture_2d<f32>;
|
||||
@group(1) @binding(1) var scene_sampler: sampler;
|
||||
|
||||
fn scene_time() -> f32 { return camera.frame.x; }
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
struct MaskOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_mask(@builtin(vertex_index) idx: u32) -> MaskOut {
|
||||
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: MaskOut;
|
||||
out.clip = vec4<f32>(p, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(p.x * 0.5 + 0.5, p.y * -0.5 + 0.5);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_mask(in: MaskOut) -> @location(0) vec4<f32> {
|
||||
let scene = textureSample(scene_tex, scene_sampler, in.uv);
|
||||
// Sky pixels carry alpha = 0; geometry carries alpha = 1.
|
||||
let is_sky = 1.0 - scene.a;
|
||||
|
||||
// Reconstruct world-space view direction at this pixel.
|
||||
let ndc_x = in.uv.x * 2.0 - 1.0;
|
||||
let ndc_y = 1.0 - in.uv.y * 2.0;
|
||||
let far_h = camera.inv_view_proj * vec4<f32>(ndc_x, ndc_y, 1.0, 1.0);
|
||||
let world_far = far_h.xyz / far_h.w;
|
||||
let view_dir = normalize(world_far - camera.eye.xyz);
|
||||
|
||||
let sun = sun_direction(scene_time());
|
||||
// Tight cone around sun. pow exponent controls cone width.
|
||||
let cone = pow(max(dot(view_dir, sun), 0.0), 8.0);
|
||||
|
||||
let mask = is_sky * cone;
|
||||
return vec4<f32>(mask, mask, mask, 1.0);
|
||||
}
|
||||
65
src/mesh.rs
65
src/mesh.rs
|
|
@ -14,15 +14,18 @@ pub struct Vertex {
|
|||
/// in the fragment shader.
|
||||
pub ao: f32,
|
||||
/// Per-vertex sky visibility baked at mesh-build time, 0..1
|
||||
/// (0 = sealed pit, 1 = open plain). Computed by
|
||||
/// `sim::lighting::sky_visibility` via 8 cosine-weighted hemisphere
|
||||
/// rays against the voxel grid — depends only on the surrounding
|
||||
/// voxel construction, so a sealed roof on the surface produces
|
||||
/// the same value as the same enclosure underground.
|
||||
///
|
||||
/// The fragment shader uses this as the geometric weight on
|
||||
/// sky-dome ambient: `sky × sky_vis + bounce × (1 − sky_vis)`.
|
||||
/// (0 = sealed pit, 1 = open plain). Geometric weight on the
|
||||
/// sky-radiance ambient contribution.
|
||||
pub sky_vis: f32,
|
||||
/// Per-vertex bounce light. RGB = average color of the surfaces
|
||||
/// this vertex's hemisphere rays hit (when they don't escape to
|
||||
/// sky). A = material id (0 = matte diffuse, 1 = slight specular
|
||||
/// for ice/snow, 2 = future emissive). Packed into one Float32x4
|
||||
/// vertex attribute. Shader uses
|
||||
/// ambient = sky_radiance × sky_vis + bounce.rgb × (1 - sky_vis)
|
||||
/// so a red brick wall casts a faint red bounce onto dirt next to
|
||||
/// it (real GI hint, baked once at mesh time).
|
||||
pub bounce_mat: [f32; 4],
|
||||
}
|
||||
|
||||
impl Vertex {
|
||||
|
|
@ -36,6 +39,7 @@ impl Vertex {
|
|||
3 => Float32,
|
||||
4 => Float32,
|
||||
5 => Float32,
|
||||
6 => Float32x4,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -207,32 +211,18 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
|||
];
|
||||
let base_idx = vertices.len() as u32;
|
||||
let corners = [c0, c1, c2, c3];
|
||||
// Bake per-corner sky visibility against the world's
|
||||
// surrounding voxel construction. 8 rays per vertex;
|
||||
// amortized at mesh-build time, free at runtime.
|
||||
// Bake per-corner ambience: (sky_vis, bounce_color)
|
||||
// from one hemisphere ray-cast pass each. Material
|
||||
// id from the underlying block.
|
||||
let normal_v = Vec3::new(n_arr[0], n_arr[1], n_arr[2]);
|
||||
let sky_vis_f = [
|
||||
crate::sim::lighting::sky_visibility(
|
||||
let material = cell.block.material_id() as f32;
|
||||
let amb: [_; 4] = std::array::from_fn(|i| {
|
||||
crate::sim::lighting::compute_ambience(
|
||||
world,
|
||||
Vec3::new(corners[0][0], corners[0][1], corners[0][2]),
|
||||
Vec3::new(corners[i][0], corners[i][1], corners[i][2]),
|
||||
normal_v,
|
||||
),
|
||||
crate::sim::lighting::sky_visibility(
|
||||
world,
|
||||
Vec3::new(corners[1][0], corners[1][1], corners[1][2]),
|
||||
normal_v,
|
||||
),
|
||||
crate::sim::lighting::sky_visibility(
|
||||
world,
|
||||
Vec3::new(corners[2][0], corners[2][1], corners[2][2]),
|
||||
normal_v,
|
||||
),
|
||||
crate::sim::lighting::sky_visibility(
|
||||
world,
|
||||
Vec3::new(corners[3][0], corners[3][1], corners[3][2]),
|
||||
normal_v,
|
||||
),
|
||||
];
|
||||
)
|
||||
});
|
||||
for i in 0..4 {
|
||||
vertices.push(Vertex {
|
||||
pos: corners[i],
|
||||
|
|
@ -240,7 +230,13 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
|||
normal: n_arr,
|
||||
leaf,
|
||||
ao: ao_f[i],
|
||||
sky_vis: sky_vis_f[i],
|
||||
sky_vis: amb[i].sky_vis,
|
||||
bounce_mat: [
|
||||
amb[i].bounce_color.x,
|
||||
amb[i].bounce_color.y,
|
||||
amb[i].bounce_color.z,
|
||||
material,
|
||||
],
|
||||
});
|
||||
}
|
||||
// Flip the diagonal when AO is "anisotropic" — i.e.
|
||||
|
|
@ -381,9 +377,10 @@ pub fn emit_oriented_box(
|
|||
normal: n_world,
|
||||
leaf: 0.0,
|
||||
ao: 1.0,
|
||||
// Remote-player boxes float in open air, so they
|
||||
// receive full sky illumination.
|
||||
// Remote-player boxes float in open air: full sky,
|
||||
// no bounce, default matte material.
|
||||
sky_vis: 1.0,
|
||||
bounce_mat: [0.35, 0.35, 0.35, 0.0],
|
||||
});
|
||||
}
|
||||
indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
// 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.
|
||||
// Post composite. Pipeline (in order):
|
||||
// 1. Read scene_color (linear HDR-ish, alpha encodes sky/geometry)
|
||||
// 2. Cheap edge-detect FXAA (4-tap diagonal blur)
|
||||
// 3. Additively composite sun shafts on top
|
||||
// 4. ACES tonemap to compress highlights into [0,1]
|
||||
// 5. Output to surface (sRGB-encoded by GPU)
|
||||
|
||||
@group(0) @binding(0) var scene_color_tex: texture_2d<f32>;
|
||||
@group(0) @binding(1) var scene_color_sampler: sampler;
|
||||
@group(0) @binding(1) var shafts_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var post_sampler: sampler;
|
||||
|
||||
struct PostOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
|
|
@ -20,12 +24,48 @@ fn vs_post(@builtin(vertex_index) idx: u32) -> PostOut {
|
|||
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;
|
||||
}
|
||||
|
||||
// Cheap edge-aware blur — a small FXAA. Samples the center and four
|
||||
// diagonal neighbors, blends toward the average where the local
|
||||
// luminance gradient exceeds a threshold. Works well for voxel games
|
||||
// where edges are axis-aligned and high-contrast.
|
||||
fn fxaa(uv: vec2<f32>, texel: vec2<f32>) -> vec3<f32> {
|
||||
let c = textureSample(scene_color_tex, post_sampler, uv).rgb;
|
||||
let nw = textureSample(scene_color_tex, post_sampler, uv + texel * vec2<f32>(-0.5, -0.5)).rgb;
|
||||
let ne = textureSample(scene_color_tex, post_sampler, uv + texel * vec2<f32>( 0.5, -0.5)).rgb;
|
||||
let sw = textureSample(scene_color_tex, post_sampler, uv + texel * vec2<f32>(-0.5, 0.5)).rgb;
|
||||
let se = textureSample(scene_color_tex, post_sampler, uv + texel * vec2<f32>( 0.5, 0.5)).rgb;
|
||||
let avg = (nw + ne + sw + se) * 0.25;
|
||||
let luma_w = vec3<f32>(0.299, 0.587, 0.114);
|
||||
let lc = dot(c, luma_w);
|
||||
let la = dot(avg, luma_w);
|
||||
let edge = clamp(abs(lc - la) * 4.0, 0.0, 1.0);
|
||||
return mix(c, avg, edge);
|
||||
}
|
||||
|
||||
// Narkowicz ACES filmic approximation. Output is linear; the sRGB
|
||||
// surface encodes for display.
|
||||
fn aces_tonemap(c: vec3<f32>) -> vec3<f32> {
|
||||
let a = 2.51;
|
||||
let b = 0.03;
|
||||
let cc = 2.43;
|
||||
let d = 0.59;
|
||||
let e = 0.14;
|
||||
return clamp((c * (a * c + b)) / (c * (cc * c + d) + e), vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_post(in: PostOut) -> @location(0) vec4<f32> {
|
||||
return textureSample(scene_color_tex, scene_color_sampler, in.uv);
|
||||
let dims = vec2<f32>(textureDimensions(scene_color_tex));
|
||||
let texel = vec2<f32>(1.0) / dims;
|
||||
|
||||
let aa = fxaa(in.uv, texel);
|
||||
let shafts = textureSample(shafts_tex, post_sampler, in.uv).rgb;
|
||||
// Additive composite — sun shafts brighten the underlying scene.
|
||||
let composed = aa + shafts;
|
||||
let tonemapped = aces_tonemap(composed);
|
||||
return vec4<f32>(tonemapped, 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ pub struct Renderer {
|
|||
pipeline: wgpu::RenderPipeline,
|
||||
sky_pipeline: wgpu::RenderPipeline,
|
||||
outline_pipeline: wgpu::RenderPipeline,
|
||||
post_pipeline: wgpu::RenderPipeline,
|
||||
|
||||
outline_buffer: wgpu::Buffer,
|
||||
depth_view: wgpu::TextureView,
|
||||
|
|
@ -52,12 +51,21 @@ pub struct Renderer {
|
|||
remote_ib: wgpu::Buffer,
|
||||
remote_index_count: u32,
|
||||
|
||||
// ---- Post processing (Step 1: pass-through scene → surface) ----
|
||||
// ---- Post processing chain: scene → mask → shafts → composite. ----
|
||||
scene_color: wgpu::TextureView,
|
||||
scene_color_format: wgpu::TextureFormat,
|
||||
post_sampler: wgpu::Sampler,
|
||||
post_bgl: wgpu::BindGroupLayout,
|
||||
post_bind_group: wgpu::BindGroup,
|
||||
image_bgl: wgpu::BindGroupLayout,
|
||||
composite_bgl: wgpu::BindGroupLayout,
|
||||
// ¼-res god-rays chain
|
||||
mask_view: wgpu::TextureView,
|
||||
shafts_view: wgpu::TextureView,
|
||||
mask_pipeline: wgpu::RenderPipeline,
|
||||
shafts_pipeline: wgpu::RenderPipeline,
|
||||
post_pipeline: wgpu::RenderPipeline,
|
||||
mask_bg: wgpu::BindGroup, // binds scene_color → input to mask pass
|
||||
shafts_bg: wgpu::BindGroup, // binds mask_view → input to shafts pass
|
||||
post_bind_group: wgpu::BindGroup, // binds scene + shafts → input to post
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
|
|
@ -252,10 +260,16 @@ impl Renderer {
|
|||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
// ---- Post pipeline + scene-color target ----
|
||||
// ---- Post chain: scene-color + ¼-res mask + ¼-res shafts → composite. ----
|
||||
let scene_color_format = config.format;
|
||||
let scene_color =
|
||||
scene_target::create_scene_color_view(&device, width, height, scene_color_format);
|
||||
let mask_view = scene_target::create_quarter_res_view(
|
||||
&device, width, height, scene_color_format,
|
||||
);
|
||||
let shafts_view = scene_target::create_quarter_res_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,
|
||||
|
|
@ -266,11 +280,53 @@ impl Renderer {
|
|||
mipmap_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
let post_bgl = pipelines::post_bgl(&device);
|
||||
let post_bind_group =
|
||||
scene_target::create_post_bind_group(&device, &post_bgl, &scene_color, &post_sampler);
|
||||
let post_pl = pipelines::pipeline_layout(&device, "post pl", &[&post_bgl]);
|
||||
let post_pipeline = pipelines::post_pipeline(&device, &post_pl, &post_shader, config.format);
|
||||
|
||||
// Bind-group layouts
|
||||
let image_bgl = pipelines::image_bgl(&device);
|
||||
let composite_bgl = pipelines::composite_bgl(&device);
|
||||
|
||||
// Shaders for the new mask + shafts passes
|
||||
let mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("mask shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
crate::shader_source::mask_shader_source().into(),
|
||||
),
|
||||
});
|
||||
let shafts_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("shafts shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
crate::shader_source::shafts_shader_source().into(),
|
||||
),
|
||||
});
|
||||
|
||||
// Pipeline layouts
|
||||
let mask_pl =
|
||||
pipelines::pipeline_layout(&device, "mask pl", &[&camera_bgl, &image_bgl]);
|
||||
let shafts_pl =
|
||||
pipelines::pipeline_layout(&device, "shafts pl", &[&camera_bgl, &image_bgl]);
|
||||
let post_pl = pipelines::pipeline_layout(&device, "post pl", &[&composite_bgl]);
|
||||
|
||||
// Pipelines (all full-screen-triangle, no depth)
|
||||
let mask_pipeline = pipelines::fullscreen_pipeline(
|
||||
&device, "mask pipeline", &mask_pl, &mask_shader, "vs_mask", "fs_mask", config.format,
|
||||
);
|
||||
let shafts_pipeline = pipelines::fullscreen_pipeline(
|
||||
&device, "shafts pipeline", &shafts_pl, &shafts_shader, "vs_shafts", "fs_shafts", config.format,
|
||||
);
|
||||
let post_pipeline = pipelines::fullscreen_pipeline(
|
||||
&device, "post pipeline", &post_pl, &post_shader, "vs_post", "fs_post", config.format,
|
||||
);
|
||||
|
||||
// Bind groups
|
||||
let mask_bg = scene_target::create_image_bind_group(
|
||||
&device, &image_bgl, &scene_color, &post_sampler,
|
||||
);
|
||||
let shafts_bg = scene_target::create_image_bind_group(
|
||||
&device, &image_bgl, &mask_view, &post_sampler,
|
||||
);
|
||||
let post_bind_group = scene_target::create_composite_bind_group(
|
||||
&device, &composite_bgl, &scene_color, &shafts_view, &post_sampler,
|
||||
);
|
||||
|
||||
Self {
|
||||
surface,
|
||||
|
|
@ -294,9 +350,16 @@ impl Renderer {
|
|||
scene_color,
|
||||
scene_color_format,
|
||||
post_sampler,
|
||||
post_bgl,
|
||||
post_bind_group,
|
||||
image_bgl,
|
||||
composite_bgl,
|
||||
mask_view,
|
||||
shafts_view,
|
||||
mask_pipeline,
|
||||
shafts_pipeline,
|
||||
post_pipeline,
|
||||
mask_bg,
|
||||
shafts_bg,
|
||||
post_bind_group,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -384,12 +447,37 @@ impl Renderer {
|
|||
height,
|
||||
self.scene_color_format,
|
||||
);
|
||||
self.post_bind_group = scene_target::create_post_bind_group(
|
||||
self.mask_view = scene_target::create_quarter_res_view(
|
||||
&self.device,
|
||||
&self.post_bgl,
|
||||
width,
|
||||
height,
|
||||
self.scene_color_format,
|
||||
);
|
||||
self.shafts_view = scene_target::create_quarter_res_view(
|
||||
&self.device,
|
||||
width,
|
||||
height,
|
||||
self.scene_color_format,
|
||||
);
|
||||
self.mask_bg = scene_target::create_image_bind_group(
|
||||
&self.device,
|
||||
&self.image_bgl,
|
||||
&self.scene_color,
|
||||
&self.post_sampler,
|
||||
);
|
||||
self.shafts_bg = scene_target::create_image_bind_group(
|
||||
&self.device,
|
||||
&self.image_bgl,
|
||||
&self.mask_view,
|
||||
&self.post_sampler,
|
||||
);
|
||||
self.post_bind_group = scene_target::create_composite_bind_group(
|
||||
&self.device,
|
||||
&self.composite_bgl,
|
||||
&self.scene_color,
|
||||
&self.shafts_view,
|
||||
&self.post_sampler,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn rebuild_chunk(&mut self, coord: IVec3, world: &World) {
|
||||
|
|
@ -432,7 +520,8 @@ impl Renderer {
|
|||
view_proj: vp.to_cols_array_2d(),
|
||||
inv_view_proj: inv.to_cols_array_2d(),
|
||||
eye: [camera.position.x, camera.position.y, camera.position.z, 1.0],
|
||||
frame: [time, 0.0, 0.0, 0.0],
|
||||
// [scene_time, exposure_bias, reserved, reserved]
|
||||
frame: [time, 1.0, 0.0, 0.0],
|
||||
};
|
||||
self.queue
|
||||
.write_buffer(&self.camera_buffer, 0, bytemuck::bytes_of(&uni));
|
||||
|
|
@ -508,26 +597,29 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- Post pass: scene_color → 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);
|
||||
}
|
||||
// ---- Post chain: mask → shafts → composite. Each step is a
|
||||
// full-screen-triangle pass with the same shape, so the chain
|
||||
// is just three calls of run_fullscreen_pass with different
|
||||
// (pipeline, target, bind groups). To add a new effect (bloom,
|
||||
// motion blur, vignette), insert another row here. ----
|
||||
run_fullscreen_pass(
|
||||
&mut encoder, "mask pass", &self.mask_view,
|
||||
&self.mask_pipeline,
|
||||
&[&self.camera_bind_group, &self.mask_bg],
|
||||
Some(wgpu::Color::BLACK),
|
||||
);
|
||||
run_fullscreen_pass(
|
||||
&mut encoder, "shafts pass", &self.shafts_view,
|
||||
&self.shafts_pipeline,
|
||||
&[&self.camera_bind_group, &self.shafts_bg],
|
||||
Some(wgpu::Color::BLACK),
|
||||
);
|
||||
run_fullscreen_pass(
|
||||
&mut encoder, "post pass", &surface_view,
|
||||
&self.post_pipeline,
|
||||
&[&self.post_bind_group],
|
||||
None,
|
||||
);
|
||||
|
||||
self.queue.submit(Some(encoder.finish()));
|
||||
frame.present();
|
||||
|
|
@ -535,6 +627,44 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run a single full-screen-triangle render pass: clear target if
|
||||
/// `clear` is `Some`, load otherwise; set pipeline; bind groups in
|
||||
/// order; draw three vertices. Used for every post-chain step so the
|
||||
/// chain reads as a declarative sequence of effects rather than
|
||||
/// boilerplate.
|
||||
fn run_fullscreen_pass(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
target: &wgpu::TextureView,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
bind_groups: &[&wgpu::BindGroup],
|
||||
clear: Option<wgpu::Color>,
|
||||
) {
|
||||
let load = match clear {
|
||||
Some(c) => wgpu::LoadOp::Clear(c),
|
||||
None => wgpu::LoadOp::Load,
|
||||
};
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
for (i, bg) in bind_groups.iter().enumerate() {
|
||||
pass.set_bind_group(i as u32, *bg, &[]);
|
||||
}
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
// ---- Browser-only compat helpers (probe WebGPU, render init-failure overlay) ----
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm_compat {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,12 @@ pub fn camera_bgl(device: &Device) -> BindGroupLayout {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn post_bgl(device: &Device) -> BindGroupLayout {
|
||||
/// Single-image input layout: one filterable texture + one filtering
|
||||
/// sampler. Used by the mask pass (reads scene_color) and the shafts
|
||||
/// pass (reads the mask). Same shape as the historical `post_bgl`.
|
||||
pub fn image_bgl(device: &Device) -> BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("post bgl"),
|
||||
label: Some("image bgl"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
|
|
@ -48,6 +51,42 @@ pub fn post_bgl(device: &Device) -> BindGroupLayout {
|
|||
})
|
||||
}
|
||||
|
||||
/// Composite layout used by the final post pass: scene_color +
|
||||
/// shafts + shared sampler.
|
||||
pub fn composite_bgl(device: &Device) -> BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("composite 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::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pipeline_layout(
|
||||
device: &Device,
|
||||
label: &str,
|
||||
|
|
@ -200,26 +239,31 @@ pub fn outline_pipeline(
|
|||
})
|
||||
}
|
||||
|
||||
/// Pass-through post: full-screen triangle that samples `scene_color`
|
||||
/// back to the surface. Foundation for FXAA / sun shafts / tonemap.
|
||||
pub fn post_pipeline(
|
||||
/// Generic full-screen-triangle pipeline. Takes the vertex + fragment
|
||||
/// entry point names so the same factory builds mask, shafts, and
|
||||
/// composite passes with different shaders. No depth, no culling, no
|
||||
/// blending — pure post-process.
|
||||
pub fn fullscreen_pipeline(
|
||||
device: &Device,
|
||||
label: &str,
|
||||
layout: &PipelineLayout,
|
||||
shader: &ShaderModule,
|
||||
vs_entry: &str,
|
||||
fs_entry: &str,
|
||||
format: TextureFormat,
|
||||
) -> RenderPipeline {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("post pipeline"),
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_post"),
|
||||
entry_point: Some(vs_entry),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_post"),
|
||||
entry_point: Some(fs_entry),
|
||||
targets: &[Some(color_target(format))],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -49,19 +49,21 @@ pub fn create_scene_color_view(
|
|||
tex.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
}
|
||||
|
||||
pub fn create_post_bind_group(
|
||||
/// One-image bind group: a single texture + a sampler. Used by the
|
||||
/// mask pass (binds scene_color) and the shafts pass (binds the mask).
|
||||
pub fn create_image_bind_group(
|
||||
device: &Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
scene: &TextureView,
|
||||
image: &TextureView,
|
||||
sampler: &Sampler,
|
||||
) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("post bg"),
|
||||
label: Some("image bg"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(scene),
|
||||
resource: wgpu::BindingResource::TextureView(image),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
|
|
@ -70,3 +72,58 @@ pub fn create_post_bind_group(
|
|||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Composite bind group for the final post pass: scene_color + shafts
|
||||
/// + shared sampler.
|
||||
pub fn create_composite_bind_group(
|
||||
device: &Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
scene: &TextureView,
|
||||
shafts: &TextureView,
|
||||
sampler: &Sampler,
|
||||
) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("composite bg"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(scene),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(shafts),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Quarter-resolution render target for the god-rays mask and shafts
|
||||
/// passes. ¼ dimensions = ¹⁄₁₆ fillrate of full screen — cheap enough
|
||||
/// to run 32-sample radial blur each frame.
|
||||
pub fn create_quarter_res_view(
|
||||
device: &Device,
|
||||
w: u32,
|
||||
h: u32,
|
||||
format: TextureFormat,
|
||||
) -> TextureView {
|
||||
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("¼-res shafts target"),
|
||||
size: wgpu::Extent3d {
|
||||
width: (w / 4).max(1),
|
||||
height: (h / 4).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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ pub struct CameraUniform {
|
|||
pub view_proj: [[f32; 4]; 4],
|
||||
pub inv_view_proj: [[f32; 4]; 4],
|
||||
pub eye: [f32; 4],
|
||||
/// `.x` = scene time in seconds (drives day/night cycle + leaf
|
||||
/// sway). `.y/.z/.w` reserved. Matches WGSL `camera.frame` with
|
||||
/// `scene_time()` accessor.
|
||||
/// Per-frame scalars; matches WGSL `camera.frame`. Accessors in
|
||||
/// the shader expose each slot by name:
|
||||
///
|
||||
/// .x scene_time seconds since session start
|
||||
/// .y exposure_bias tonemap multiplier (default 1.0)
|
||||
/// .z reserved
|
||||
/// .w reserved
|
||||
pub frame: [f32; 4],
|
||||
}
|
||||
|
||||
|
|
|
|||
154
src/shader.wgsl
154
src/shader.wgsl
|
|
@ -16,18 +16,26 @@
|
|||
|
||||
// ---------------- 1. Camera ----------------
|
||||
|
||||
// Camera uniform — layout mirrored in `render::uniform::CameraUniform`.
|
||||
// The `frame` vec4 is the "per-frame scalars" slot; the accessor
|
||||
// functions below name each component so callsites read as intent
|
||||
// rather than `frame.x` / `frame.y` / etc.
|
||||
//
|
||||
// frame.x scene_time seconds since session start
|
||||
// frame.y exposure_bias tonemap multiplier (default 1.0)
|
||||
// frame.z reserved for future fog density / weather etc.
|
||||
// frame.w reserved
|
||||
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)
|
||||
/// .y/.z/.w reserved
|
||||
frame: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> camera: Camera;
|
||||
|
||||
fn scene_time() -> f32 { return camera.frame.x; }
|
||||
fn exposure_bias() -> f32 { return camera.frame.y; }
|
||||
fn eye_world() -> vec3<f32> { return camera.eye.xyz; }
|
||||
|
||||
// ---------------- 2. Sky horizon math (shared with ambient) ----------------
|
||||
|
|
@ -64,10 +72,15 @@ fn sky_dome(dir: vec3<f32>, sun: vec3<f32>) -> vec3<f32> {
|
|||
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);
|
||||
// Dusky-purple zenith during twilight — in reality the entire sky
|
||||
// tints warm at sunset, not just the horizon. Without this, top
|
||||
// faces stay cold blue while the horizon visibly burns orange.
|
||||
let zenith_twi = vec3<f32>(0.45, 0.28, 0.40);
|
||||
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 zenith_base = mix(zenith_night, zenith_day, day);
|
||||
let zenith = mix(zenith_base, zenith_twi, twi * 0.65);
|
||||
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);
|
||||
|
|
@ -127,11 +140,15 @@ fn sky_color(dir: vec3<f32>) -> vec3<f32> {
|
|||
let below = step(up, 0.0) * 0.2;
|
||||
sky = sky * (1.0 - below);
|
||||
|
||||
// Stars: fade in at night with slight twinkle.
|
||||
if (night > 0.05) {
|
||||
// Stars: only visible once the sun is well below the horizon. The
|
||||
// old `1 - day` gate showed stars during twilight (when day < 1 but
|
||||
// the sky was still bright). The new gate is tied to sun altitude
|
||||
// directly so stars fade in *after* civil twilight, not during it.
|
||||
let star_fade = 1.0 - smoothstep(-0.22, 0.04, sun.y);
|
||||
if (star_fade > 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 * twinkle);
|
||||
sky = sky + vec3<f32>(st * star_fade * twinkle);
|
||||
}
|
||||
|
||||
// Cloud layer — fbm scrolled across an imaginary plane high above.
|
||||
|
|
@ -146,11 +163,16 @@ fn sky_color(dir: vec3<f32>) -> vec3<f32> {
|
|||
sky = mix(sky, cloud_col, mask * (0.55 + 0.25 * day));
|
||||
}
|
||||
|
||||
// Sun disc + halo.
|
||||
// Sun disc + halo. The disc softens and spreads as the sun nears
|
||||
// the horizon — atmospheric scattering blooms the apparent disc at
|
||||
// low angles. Sharp pin-point at zenith, big soft circle at dusk.
|
||||
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;
|
||||
let alt = clamp(sun.y, 0.0, 1.0);
|
||||
let disc_sharpness = mix(160.0, 800.0, alt);
|
||||
let disc_intensity = mix(2.2, 1.5, alt);
|
||||
let disc = pow(cos_s, disc_sharpness) * disc_intensity * smoothstep(-0.05, 0.05, sun.y);
|
||||
let halo = pow(cos_s, mix(3.0, 5.0, alt)) * mix(0.35, 0.20, alt) * day;
|
||||
sky = sky + sun_col * (disc + halo);
|
||||
|
||||
// Moon disc — opposite the sun, faint white, night only.
|
||||
|
|
@ -176,33 +198,47 @@ fn sky_color(dir: vec3<f32>) -> vec3<f32> {
|
|||
///
|
||||
/// ambient = sky_radiance(N) × sky_vis + bounce × (1 − sky_vis)
|
||||
///
|
||||
/// The two terms separate the *geometric* visibility of the sky
|
||||
/// (baked into `sky_vis` per vertex from a ray-cast against the actual
|
||||
/// voxel construction) from the *radiometric* sky color (computed
|
||||
/// fresh each frame from sun position). Multiplying them gives the
|
||||
/// correct behavior in every condition without a single-knob hack:
|
||||
/// Both visibility *and* bounce color are baked per vertex by
|
||||
/// `sim::lighting::compute_ambience` (same 8 cosine-weighted rays
|
||||
/// produce sky_vis and average bounce color in one pass). The
|
||||
/// shader's job is just to combine the baked geometric terms with the
|
||||
/// time-of-day radiometric terms.
|
||||
///
|
||||
/// - Open plain at noon: sky_vis≈1 → full bright sky-color ambient.
|
||||
/// - Deep cave at noon: sky_vis≈0 → tiny sun-bounce only.
|
||||
/// - Player-built roof: sky_vis≈0 → same as deep cave, by virtue
|
||||
/// of the surrounding voxels — no "is this
|
||||
/// the surface or underground?" hack.
|
||||
/// - Side face in a notch: sky_vis tracks the *fraction* of sky the
|
||||
/// vertex actually sees, so it dims smoothly
|
||||
/// as the player walls things in.
|
||||
///
|
||||
/// `bounce` approximates the contribution from light bouncing off
|
||||
/// neighboring voxels — we don't sample neighbor colors yet (that's
|
||||
/// Phase 2: bake bounce color per vertex), so a constant gray-brown
|
||||
/// tinted by the sun is the cheapest reasonable stand-in.
|
||||
fn ambient_term(normal: vec3<f32>, sun: vec3<f32>, day: f32, sky_vis: f32) -> vec3<f32> {
|
||||
/// - Open plain at noon: sky_vis≈1 → full bright sky-color ambient
|
||||
/// - Deep cave at noon: sky_vis≈0 → bounce_color × sun strength
|
||||
/// - Red brick wall corner: bounce_color carries the red tint from
|
||||
/// the wall, giving a faint red glow on
|
||||
/// adjacent surfaces (real GI hint, free at
|
||||
/// runtime).
|
||||
fn ambient_term(normal: vec3<f32>, sun: vec3<f32>, day: f32, sky_vis: f32, bounce_albedo: vec3<f32>) -> vec3<f32> {
|
||||
let sky = sky_dome(normal, sun);
|
||||
let bounce_albedo = vec3<f32>(0.42, 0.40, 0.34);
|
||||
let bounce_strength = mix(0.04, 1.0, day);
|
||||
let bounce = bounce_albedo * bounce_strength * sun_tint(sun);
|
||||
return sky * sky_vis + bounce * (1.0 - sky_vis);
|
||||
}
|
||||
|
||||
/// Material lookup. Keyed by `bounce_mat.a` (set at mesh-build time
|
||||
/// from `Block::material_id`). Keep in sync with the Rust enum.
|
||||
struct Material {
|
||||
specular: f32,
|
||||
emission: vec3<f32>,
|
||||
};
|
||||
|
||||
fn material_for(id_f: f32) -> Material {
|
||||
let id = u32(id_f + 0.5);
|
||||
var m: Material;
|
||||
m.specular = 0.0;
|
||||
m.emission = vec3<f32>(0.0);
|
||||
if (id == 1u) {
|
||||
// Ice / snow — slight specular highlight.
|
||||
m.specular = 0.45;
|
||||
} else if (id == 2u) {
|
||||
// Reserved: emissive (future glowing blocks).
|
||||
m.emission = vec3<f32>(1.00, 0.70, 0.40);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/// Direct-sun Lambert term, gated by sun visibility above the horizon.
|
||||
/// Returns a scalar 0..1 — caller multiplies by `sun_tint(sun)` for
|
||||
/// color.
|
||||
|
|
@ -225,6 +261,21 @@ fn leaf_jitter(world_pos: vec3<f32>) -> f32 {
|
|||
return 0.88 + n * 0.18;
|
||||
}
|
||||
|
||||
/// Approximated leaf translucency. When the sun is behind the leaf
|
||||
/// (relative to the surface normal), some light transmits through to
|
||||
/// the viewer's side and the leaf glows softly with sun-tinted color.
|
||||
/// Cheap stand-in for full subsurface scattering; reads as "sun
|
||||
/// through canopy" without per-pixel sampling.
|
||||
fn leaf_translucency(normal: vec3<f32>, sun: vec3<f32>, day: f32) -> f32 {
|
||||
// peak transmittance when sun rakes across the leaf plane — i.e.
|
||||
// when sun is roughly perpendicular to the normal (grazing).
|
||||
let grazing = 1.0 - abs(dot(normal, sun));
|
||||
// backlit bias: prefer light coming from BEHIND the leaf so we
|
||||
// don't double-count the front-side Lambert from direct_sun_term.
|
||||
let back = max(dot(-normal, sun), 0.0);
|
||||
return grazing * back * 0.55 * day;
|
||||
}
|
||||
|
||||
/// Distance fog. Returns 0 (no fog) → 1 (fully obscured).
|
||||
fn fog_factor(dist: f32) -> f32 {
|
||||
let fog_start = 90.0;
|
||||
|
|
@ -234,14 +285,19 @@ fn fog_factor(dist: f32) -> f32 {
|
|||
|
||||
/// Blend `lit` toward sky color along the view ray when the fragment
|
||||
/// is far enough to be fogged. Defers the (expensive) full `sky_color`
|
||||
/// call until the factor is actually nonzero.
|
||||
/// call until the factor is actually nonzero. At twilight the fog
|
||||
/// further biases toward warm sun-tint so distant terrain reads
|
||||
/// orange/pink against an orange sky instead of cold against orange.
|
||||
fn apply_fog(lit: vec3<f32>, dist: f32, view_dir: vec3<f32>) -> vec3<f32> {
|
||||
let t = fog_factor(dist);
|
||||
if (t <= 0.001) {
|
||||
return lit;
|
||||
}
|
||||
let sun = sun_direction(scene_time());
|
||||
let twi = twilight_amount(sun);
|
||||
let sky = sky_color(-view_dir);
|
||||
return mix(lit, sky, t);
|
||||
let fog_col = mix(sky, sky * sun_tint(sun), twi * 0.45);
|
||||
return mix(lit, fog_col, t);
|
||||
}
|
||||
|
||||
// ---------------- 5a. Terrain pipeline ----------------
|
||||
|
|
@ -253,6 +309,7 @@ struct VsIn {
|
|||
@location(3) leaf: f32,
|
||||
@location(4) ao: f32,
|
||||
@location(5) sky_vis: f32,
|
||||
@location(6) bounce_mat: vec4<f32>, // rgb = baked bounce color, a = material id
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
|
|
@ -263,6 +320,7 @@ struct VsOut {
|
|||
@location(3) leaf: f32,
|
||||
@location(4) ao: f32,
|
||||
@location(5) sky_vis: f32,
|
||||
@location(6) bounce_mat: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
|
|
@ -285,6 +343,7 @@ fn vs_main(in: VsIn) -> VsOut {
|
|||
out.leaf = in.leaf;
|
||||
out.ao = in.ao;
|
||||
out.sky_vis = in.sky_vis;
|
||||
out.bounce_mat = in.bounce_mat;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -296,22 +355,35 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
|||
|
||||
let n = normalize(in.normal);
|
||||
|
||||
// Lighting pipeline:
|
||||
// ambient (sky_vis-weighted) + direct_sun → modulated by base color
|
||||
// → AO → leaf jitter → fog
|
||||
let ambient = ambient_term(n, sun, day, in.sky_vis);
|
||||
// With sky_vis carrying the per-vertex geometric weight, the global
|
||||
// ambient_strength is just an overall brightness knob; we can push
|
||||
// it to ~1.0 at full day without washing out caves.
|
||||
// Lighting pipeline (all per-fragment scalars from baked vertex
|
||||
// attributes + time-of-day functions):
|
||||
// ambient (sky_vis-weighted + baked bounce_color)
|
||||
// + direct_sun (Lambert × sun_visible)
|
||||
// + specular (Phong, gated by material)
|
||||
// + emission (gated by material)
|
||||
// then base color × AO × leaf jitter, then fog.
|
||||
let bounce_albedo = in.bounce_mat.rgb;
|
||||
let mat = material_for(in.bounce_mat.a);
|
||||
|
||||
let ambient = ambient_term(n, sun, day, in.sky_vis, bounce_albedo);
|
||||
let ambient_strength = mix(0.35, 1.00, day);
|
||||
let sun_term = direct_sun_term(n, sun);
|
||||
let sun_col = sun_tint(sun);
|
||||
|
||||
// Specular: Phong with half-vector. Gated by material + sun visibility.
|
||||
let to_eye = normalize(eye_world() - in.world_pos);
|
||||
let half_v = normalize(sun + to_eye);
|
||||
let spec_dot = max(dot(n, half_v), 0.0);
|
||||
let sun_visible = smoothstep(-0.05, 0.10, sun.y);
|
||||
let specular = pow(spec_dot, 48.0) * mat.specular * sun_visible;
|
||||
|
||||
let lighting = ambient * ambient_strength + sun_col * sun_term;
|
||||
var lit = in.color * lighting * in.ao;
|
||||
var lit = in.color * lighting * in.ao + sun_col * specular + mat.emission;
|
||||
|
||||
if (in.leaf > 0.5) {
|
||||
lit = lit * leaf_jitter(in.world_pos);
|
||||
let trans = leaf_translucency(n, sun, day);
|
||||
lit = lit + in.color * sun_col * trans;
|
||||
}
|
||||
|
||||
let to_eye = eye_world() - in.world_pos;
|
||||
|
|
@ -348,7 +420,9 @@ fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
|||
let far_h = camera.inv_view_proj * vec4<f32>(in.ndc.x, in.ndc.y, 1.0, 1.0);
|
||||
let world_pos = far_h.xyz / far_h.w;
|
||||
let dir = normalize(world_pos - eye_world());
|
||||
return vec4<f32>(sky_color(dir), 1.0);
|
||||
// alpha = 0 flags this pixel as "sky" for the god-rays mask pass;
|
||||
// terrain pipeline writes alpha = 1 where it overdraws.
|
||||
return vec4<f32>(sky_color(dir), 0.0);
|
||||
}
|
||||
|
||||
// ---------------- 5c. Outline ----------------
|
||||
|
|
|
|||
|
|
@ -26,8 +26,21 @@ pub fn terrain_shader_source() -> String {
|
|||
format!("{}{}", wgsl_constants_header(), include_str!("shader.wgsl"))
|
||||
}
|
||||
|
||||
/// Post-process pipeline source. Doesn't depend on the shared
|
||||
/// constants; passed through for symmetry with `terrain_shader_source`.
|
||||
/// God-rays sun-cone mask shader. Reads scene_color, gates by sun
|
||||
/// direction. Needs DAY_PERIOD / SUN_OFFSET injected.
|
||||
pub fn mask_shader_source() -> String {
|
||||
format!("{}{}", wgsl_constants_header(), include_str!("mask.wgsl"))
|
||||
}
|
||||
|
||||
/// God-rays radial-blur pass. Reads the mask texture, accumulates
|
||||
/// samples toward the sun's screen-space position. Needs the same
|
||||
/// injected sun constants.
|
||||
pub fn shafts_shader_source() -> String {
|
||||
format!("{}{}", wgsl_constants_header(), include_str!("shafts.wgsl"))
|
||||
}
|
||||
|
||||
/// Post-composite pipeline source: FXAA + shafts composite + tonemap.
|
||||
/// Doesn't reference sun-direction math; no constants needed.
|
||||
pub fn post_shader_source() -> &'static str {
|
||||
include_str!("post.wgsl")
|
||||
}
|
||||
|
|
@ -51,4 +64,20 @@ mod tests {
|
|||
assert!(src.contains("fn sun_direction"));
|
||||
assert!(src.contains("fn fs_main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_shader_source_has_constants_and_entry_points() {
|
||||
let src = mask_shader_source();
|
||||
assert!(src.contains("DAY_PERIOD"));
|
||||
assert!(src.contains("fn vs_mask"));
|
||||
assert!(src.contains("fn fs_mask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shafts_shader_source_has_constants_and_entry_points() {
|
||||
let src = shafts_shader_source();
|
||||
assert!(src.contains("DAY_PERIOD"));
|
||||
assert!(src.contains("fn vs_shafts"));
|
||||
assert!(src.contains("fn fs_shafts"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
src/shafts.wgsl
Normal file
95
src/shafts.wgsl
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// Screen-space radial blur from the sun's projected screen position.
|
||||
// Reads the sun-cone sky mask, accumulates samples along the ray from
|
||||
// this pixel toward the sun, and outputs sun-tinted ray intensity for
|
||||
// the post composite to add onto the scene.
|
||||
//
|
||||
// Runs at ¼ resolution. 32 samples with exponential decay.
|
||||
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
inv_view_proj: mat4x4<f32>,
|
||||
eye: vec4<f32>,
|
||||
frame: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> camera: Camera;
|
||||
@group(1) @binding(0) var mask_tex: texture_2d<f32>;
|
||||
@group(1) @binding(1) var mask_sampler: sampler;
|
||||
|
||||
fn scene_time() -> f32 { return camera.frame.x; }
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
fn twilight_amount(sun: vec3<f32>) -> f32 {
|
||||
return smoothstep(-0.10, 0.05, sun.y) - smoothstep(0.05, 0.30, sun.y);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
struct ShaftsOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_shafts(@builtin(vertex_index) idx: u32) -> ShaftsOut {
|
||||
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: ShaftsOut;
|
||||
out.clip = vec4<f32>(p, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(p.x * 0.5 + 0.5, p.y * -0.5 + 0.5);
|
||||
return out;
|
||||
}
|
||||
|
||||
const N_SAMPLES: i32 = 32;
|
||||
const DECAY: f32 = 0.965;
|
||||
const WEIGHT: f32 = 0.42;
|
||||
const EXPOSURE: f32 = 0.30;
|
||||
|
||||
@fragment
|
||||
fn fs_shafts(in: ShaftsOut) -> @location(0) vec4<f32> {
|
||||
let sun = sun_direction(scene_time());
|
||||
if (sun.y < -0.05) {
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Project the sun direction at infinity to screen space.
|
||||
// view_proj × (sun, 0.0) yields the homogeneous coords of a point
|
||||
// infinitely far along the sun direction.
|
||||
let sun_clip = camera.view_proj * vec4<f32>(sun, 0.0);
|
||||
if (sun_clip.w <= 0.0) {
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
let sun_ndc = sun_clip.xyz / sun_clip.w;
|
||||
let sun_uv = vec2<f32>(sun_ndc.x * 0.5 + 0.5, sun_ndc.y * -0.5 + 0.5);
|
||||
|
||||
// Step from this pixel toward the sun. Sample mask each step,
|
||||
// accumulate with exponential illumination decay.
|
||||
let delta = (sun_uv - in.uv) / f32(N_SAMPLES);
|
||||
var coord = in.uv;
|
||||
var illum_decay = 1.0;
|
||||
var accum = 0.0;
|
||||
for (var i = 0; i < N_SAMPLES; i = i + 1) {
|
||||
coord = coord + delta;
|
||||
let m = textureSample(mask_tex, mask_sampler, coord).r;
|
||||
accum = accum + m * illum_decay * WEIGHT;
|
||||
illum_decay = illum_decay * DECAY;
|
||||
}
|
||||
|
||||
// Gate by sun altitude (no shafts at night). At low sun the rays
|
||||
// are most dramatic — keep intensity flat over the visible range.
|
||||
let visibility = smoothstep(-0.05, 0.15, sun.y);
|
||||
let intensity = accum * EXPOSURE * visibility;
|
||||
|
||||
return vec4<f32>(sun_tint(sun) * intensity, 1.0);
|
||||
}
|
||||
|
|
@ -158,19 +158,40 @@ const COSINE_HEMI_RAYS: [[f32; 3]; 8] = [
|
|||
/// Fraction of the upper hemisphere (relative to `normal`) that has an
|
||||
/// unobstructed view of the sky. 0.0 = sealed pit, 1.0 = open plain.
|
||||
///
|
||||
/// Cast 8 cosine-weighted rays through the world's voxels via
|
||||
/// `walks_to_sky` and count escapes. Pure function of (world, position,
|
||||
/// normal) — depends only on the *surrounding voxel construction*, so a
|
||||
/// player-built roof on the surface produces the same sky_vis as
|
||||
/// equivalently-enclosed geometry deep underground. No special cases.
|
||||
///
|
||||
/// Computed once at mesh-build time and baked per vertex; the fragment
|
||||
/// shader pays one multiply at runtime.
|
||||
/// Backed by `compute_ambience` — the same ray-cast simultaneously
|
||||
/// produces both sky_vis AND the bounce-color average. Most callers
|
||||
/// want both; the standalone wrapper exists for tests and clarity.
|
||||
pub fn sky_visibility(world: &World, pos: Vec3, normal: Vec3) -> f32 {
|
||||
compute_ambience(world, pos, normal).sky_vis
|
||||
}
|
||||
|
||||
/// Both ambience scalars baked at a single point on a face. Computed
|
||||
/// from the *same* 8 cosine-weighted rays so we don't double-walk the
|
||||
/// voxel grid: each ray either escapes (counts toward sky_vis) or hits
|
||||
/// a solid voxel (contributes its average_color toward bounce_color).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct VertexAmbience {
|
||||
pub sky_vis: f32,
|
||||
pub bounce_color: Vec3,
|
||||
}
|
||||
|
||||
/// Cast 8 cosine-weighted rays into the upper hemisphere relative to
|
||||
/// `normal`. Track:
|
||||
/// - how many escape to open sky (→ sky_vis)
|
||||
/// - the average color of the first solid voxel each non-escaping
|
||||
/// ray hits (→ bounce_color)
|
||||
///
|
||||
/// This is the *bake* call that runs once per quad-corner at
|
||||
/// mesh-build time. Cheap CPU work, amortized — the fragment shader
|
||||
/// then pays one multiply for the sky contribution and one for the
|
||||
/// bounce contribution. Together they give:
|
||||
///
|
||||
/// ambient = sky_radiance(N) × sky_vis + bounce_color × (1 − sky_vis)
|
||||
///
|
||||
/// A red brick wall thus casts a faint red bounce on the dirt next to
|
||||
/// it; a sealed roof darkens but inherits the color of its underside.
|
||||
pub fn compute_ambience(world: &World, pos: Vec3, normal: Vec3) -> VertexAmbience {
|
||||
let n = normal.normalize();
|
||||
// Build an orthonormal tangent basis. The "if x is small, cross
|
||||
// with X; else cross with Y" trick avoids the degeneracy when the
|
||||
// normal is itself ±X.
|
||||
let tangent = if n.x.abs() < 0.9 {
|
||||
Vec3::X.cross(n).normalize()
|
||||
} else {
|
||||
|
|
@ -178,14 +199,80 @@ pub fn sky_visibility(world: &World, pos: Vec3, normal: Vec3) -> f32 {
|
|||
};
|
||||
let bitangent = n.cross(tangent);
|
||||
|
||||
let mut hits = 0u32;
|
||||
let mut sky_hits = 0u32;
|
||||
let mut bounce_sum = Vec3::ZERO;
|
||||
let mut bounce_count = 0u32;
|
||||
|
||||
for ray in COSINE_HEMI_RAYS.iter() {
|
||||
let dir = tangent * ray[0] + bitangent * ray[1] + n * ray[2];
|
||||
if walks_to_sky(world, pos, dir) {
|
||||
hits += 1;
|
||||
match walks_until_solid(world, pos, dir) {
|
||||
None => sky_hits += 1,
|
||||
Some(color) => {
|
||||
bounce_sum += color;
|
||||
bounce_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
hits as f32 / COSINE_HEMI_RAYS.len() as f32
|
||||
|
||||
let n_rays = COSINE_HEMI_RAYS.len() as f32;
|
||||
let sky_vis = sky_hits as f32 / n_rays;
|
||||
let bounce_color = if bounce_count > 0 {
|
||||
bounce_sum / bounce_count as f32
|
||||
} else {
|
||||
// No occluders sampled — neutral gray bounce is the safe
|
||||
// fallback; the shader weights this by (1 - sky_vis) anyway
|
||||
// so a fully-open vertex barely uses this value.
|
||||
Vec3::splat(0.35)
|
||||
};
|
||||
|
||||
VertexAmbience { sky_vis, bounce_color }
|
||||
}
|
||||
|
||||
/// Walk a DDA ray through the voxel grid like `walks_to_sky`, but
|
||||
/// distinguish "escaped to sky" (returns `None`) from "hit solid"
|
||||
/// (returns `Some(block_average_color)`). Used by `compute_ambience`
|
||||
/// to bake both sky-vis and bounce-color in one pass.
|
||||
fn walks_until_solid(world: &World, origin: Vec3, dir: Vec3) -> Option<Vec3> {
|
||||
let p = origin + dir * 0.001;
|
||||
|
||||
let mut cell = IVec3::new(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let step = IVec3::new(
|
||||
sign_to_step(dir.x),
|
||||
sign_to_step(dir.y),
|
||||
sign_to_step(dir.z),
|
||||
);
|
||||
let t_delta = Vec3::new(
|
||||
axis_t_delta(dir.x),
|
||||
axis_t_delta(dir.y),
|
||||
axis_t_delta(dir.z),
|
||||
);
|
||||
let mut t_max = Vec3::new(
|
||||
first_t_max(p.x, dir.x, cell.x),
|
||||
first_t_max(p.y, dir.y, cell.y),
|
||||
first_t_max(p.z, dir.z, cell.z),
|
||||
);
|
||||
|
||||
for _ in 0..SUN_RAY_MAX_STEPS {
|
||||
if cell.y >= SUN_RAY_TOP_Y {
|
||||
return None;
|
||||
}
|
||||
if t_max.x < t_max.y && t_max.x < t_max.z {
|
||||
cell.x += step.x;
|
||||
t_max.x += t_delta.x;
|
||||
} else if t_max.y < t_max.z {
|
||||
cell.y += step.y;
|
||||
t_max.y += t_delta.y;
|
||||
} else {
|
||||
cell.z += step.z;
|
||||
t_max.z += t_delta.z;
|
||||
}
|
||||
let block = world.get_block(cell);
|
||||
if block.solid() {
|
||||
let c = block.average_color();
|
||||
return Some(Vec3::new(c[0], c[1], c[2]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn sign_to_step(v: f32) -> i32 {
|
||||
|
|
|
|||
26
src/world.rs
26
src/world.rs
|
|
@ -44,6 +44,32 @@ impl Block {
|
|||
(Block::Air, _) => [0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Average diffuse color across all six faces — the color used
|
||||
/// when this block contributes bounce light to a neighboring
|
||||
/// surface during mesh-build's ambience bake.
|
||||
pub fn average_color(self) -> [f32; 3] {
|
||||
let mut s = [0.0_f32; 3];
|
||||
for f in Face::ALL {
|
||||
let c = self.face_color(f);
|
||||
s[0] += c[0];
|
||||
s[1] += c[1];
|
||||
s[2] += c[2];
|
||||
}
|
||||
[s[0] / 6.0, s[1] / 6.0, s[2] / 6.0]
|
||||
}
|
||||
|
||||
/// Material id consumed by the shader to pick specular / emission
|
||||
/// behavior. Matches the `material_for(id)` switch in `shader.wgsl`:
|
||||
/// 0 matte diffuse (default)
|
||||
/// 1 slight specular (ice, snow)
|
||||
/// 2 emissive (future glowing blocks)
|
||||
pub fn material_id(self) -> u32 {
|
||||
match self {
|
||||
Block::Ice | Block::Snow => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
|
|
|
|||
5
test/.gitignore
vendored
Normal file
5
test/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.browser_profile/
|
||||
screenshots/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
102
test/README.md
Normal file
102
test/README.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Test harness — declarative visual + behavioral scenarios
|
||||
|
||||
Mirrors the cucucaracha (lacucarachanews) toolkit pattern: `launch.py`
|
||||
opens a Chromium with persistent profile + CDP on port 9222; small
|
||||
attach-only tools drive the *same* session via Playwright.
|
||||
|
||||
What's different: the game exposes a small set of wasm bindings so
|
||||
scenarios can declaratively set scene state and read back telemetry,
|
||||
not just click DOM elements. See `src/bridges.rs` `wasm_api` for the
|
||||
exports.
|
||||
|
||||
## Setup (once)
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Open the game in a controllable browser
|
||||
|
||||
```sh
|
||||
python3 launch.py # opens https://voxel.mxvs.art
|
||||
python3 launch.py --url http://localhost:8080 # local dev server
|
||||
```
|
||||
|
||||
The browser stays open; profile lives at `./.browser_profile/`. CDP
|
||||
listens on `localhost:9222` so the other tools can attach.
|
||||
|
||||
## One-shot inspection / screenshot
|
||||
|
||||
```sh
|
||||
python3 peek.py # screenshot + dump game telemetry
|
||||
python3 peek.py --json # machine-readable
|
||||
```
|
||||
|
||||
Writes a PNG to `screenshots/<ts>_peek.png`.
|
||||
|
||||
## Run a scenario
|
||||
|
||||
```sh
|
||||
python3 run.py scenarios/lighting-times-of-day.yaml
|
||||
```
|
||||
|
||||
Scenarios are YAML lists of `steps`. Each step is one of:
|
||||
|
||||
| step | meaning |
|
||||
|-----------------------------|---------|
|
||||
| `wait_for: <js_expr>` | block until `js_expr` evaluates truthy |
|
||||
| `wait: <ms>` | sleep that many ms |
|
||||
| `eval: <js>` | run JS in the page (state setters, etc.) |
|
||||
| `key: <key> [hold: ms]` | press a key (optionally hold) |
|
||||
| `mouse_move: [dx, dy]` | relative mouse motion |
|
||||
| `mouse: <down\|up\|click>` | mouse button events |
|
||||
| `screenshot: <name>.png` | save canvas screenshot to `screenshots/` |
|
||||
| `assert: <js_expr>` | fail scenario if `js_expr` is falsy |
|
||||
|
||||
## Available game-state JS bindings
|
||||
|
||||
All exported by the wasm module (see `src/bridges.rs::wasm_api`).
|
||||
After `wait_for: "window.voxel_game !== undefined"`, call as
|
||||
`window.voxel_game.<fn>(...)`.
|
||||
|
||||
| Setter | Effect |
|
||||
|--------|--------|
|
||||
| `set_scene_time(t: f32)` | jump shader time to `t` seconds |
|
||||
| `set_time_scale(s: f32)` | freeze (0) / fast-forward time |
|
||||
| `teleport(x, y, z)` | move player feet to (x,y,z) |
|
||||
| `look_at(yaw, pitch)` | set camera angles (radians) |
|
||||
| `set_paused(b)` | pause input + physics |
|
||||
| `set_fov(deg)` / `set_mouse_sens(s)` / `set_render_distance(blocks)` | settings |
|
||||
| `respawn()` | one-shot respawn request |
|
||||
|
||||
| Getter | Returns |
|
||||
|--------|---------|
|
||||
| `get_scene_time()` | `f32` |
|
||||
| `get_position()` | `[x, y, z]` |
|
||||
| `get_camera_angles()` | `[yaw, pitch]` |
|
||||
| `get_hp()` / `is_alive()` | from death/respawn state |
|
||||
|
||||
## Why this exists
|
||||
|
||||
When something looks wrong (e.g. "tops of blocks don't react to
|
||||
sunset"), the dev loop without this is: deploy, open browser, fiddle
|
||||
the time slider, compare to baseline by memory. With this, the loop
|
||||
is: write a scenario that screenshots the same view at noon / sunset /
|
||||
midnight, run it, diff the screenshots against a baseline. Bugs become
|
||||
visible in one command.
|
||||
|
||||
For non-visual behaviors (e.g. "is the joystick releasing correctly?")
|
||||
the same harness sends keyboard events and reads back position
|
||||
telemetry to assert "after release, player stops moving within N ms".
|
||||
|
||||
## Sanity guarantees
|
||||
|
||||
- Persistent profile means cookies, settings, and game state survive
|
||||
across `launch.py` runs. The first launch is "fresh"; subsequent
|
||||
ones resume where you left off.
|
||||
- `launch.py` never touches the page beyond the initial navigation.
|
||||
Scenarios drive the session; the launcher just hosts the browser.
|
||||
- All tools attach via CDP — closing them doesn't close the browser.
|
||||
97
test/launch.py
Normal file
97
test/launch.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""
|
||||
Launch Chromium pointed at the voxel game with CDP attached.
|
||||
|
||||
Mirrors the cucucaracha launch pattern:
|
||||
- Persistent profile dir (./.browser_profile/) so settings + cookies
|
||||
survive between runs.
|
||||
- Remote debugging on port 9222 so peek.py / run.py attach to the
|
||||
same session.
|
||||
- Disables saved-password autofill (irrelevant here but harmless).
|
||||
- Navigates ONCE to the target URL and sits idle.
|
||||
|
||||
The browser stays open until you Ctrl-C this terminal or close the
|
||||
window. Scenarios are driven from a second terminal via run.py.
|
||||
|
||||
Usage:
|
||||
python3 launch.py
|
||||
python3 launch.py --url http://localhost:8080
|
||||
python3 launch.py --url http://localhost:8080 --headed/--headless
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
USER_DATA_DIR = HERE / ".browser_profile"
|
||||
DEFAULT_URL = "https://voxel.mxvs.art/"
|
||||
CDP_PORT = 9222
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--url", default=DEFAULT_URL, help="game URL to open")
|
||||
ap.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
help="run headless (no visible window). Default is headed.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
USER_DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
chrome_args = [
|
||||
f"--remote-debugging-port={CDP_PORT}",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
# WebGPU benefits from these on Linux — voxel game prefers
|
||||
# WebGPU but will fall back to WebGL2 if disabled.
|
||||
"--enable-unsafe-webgpu",
|
||||
# Smaller window default
|
||||
"--window-size=1280,800",
|
||||
]
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=str(USER_DATA_DIR),
|
||||
headless=args.headless,
|
||||
viewport={"width": 1280, "height": 800},
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
|
||||
try:
|
||||
page.goto(args.url, wait_until="domcontentloaded", timeout=45000)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(
|
||||
f"[!] navigation error (browser stays open): {e}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(
|
||||
f"[i] Browser open at {args.url}\n"
|
||||
f" CDP listening on http://localhost:{CDP_PORT}\n"
|
||||
f" Profile: {USER_DATA_DIR}\n"
|
||||
" Use peek.py / run.py from another terminal.\n"
|
||||
" Stop with Ctrl-C or close the browser window.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
print("[i] Ctrl-C received; exiting.", file=sys.stderr, flush=True)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
114
test/peek.py
Normal file
114
test/peek.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""
|
||||
One-shot inspection: attach to the running launch.py browser via CDP,
|
||||
screenshot the canvas, and dump current game telemetry (scene time,
|
||||
player position, camera angles, hp).
|
||||
|
||||
Read-only. Cannot send inputs, change state, or close the browser.
|
||||
|
||||
Usage:
|
||||
python3 peek.py
|
||||
python3 peek.py --json
|
||||
python3 peek.py --name baseline # custom screenshot filename
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SCREENSHOT_DIR = HERE / "screenshots"
|
||||
CDP_URL = "http://localhost:9222"
|
||||
|
||||
|
||||
# JS evaluated in the page to gather game state. Pure reads only.
|
||||
TELEMETRY_JS = r"""
|
||||
() => {
|
||||
const g = window.voxel_game;
|
||||
if (!g) return { ready: false };
|
||||
return {
|
||||
ready: true,
|
||||
scene_time: g.get_scene_time?.() ?? null,
|
||||
position: g.get_position?.() ?? null,
|
||||
camera_angles: g.get_camera_angles?.() ?? null,
|
||||
hp: g.get_hp?.() ?? null,
|
||||
alive: g.is_alive?.() ?? null,
|
||||
canvas_size: (() => {
|
||||
const c = document.getElementById('game-canvas');
|
||||
return c ? { w: c.width, h: c.height } : null;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def attach_page():
|
||||
p = sync_playwright().start()
|
||||
try:
|
||||
browser = p.chromium.connect_over_cdp(CDP_URL)
|
||||
except Exception as e: # noqa: BLE001
|
||||
p.stop()
|
||||
sys.exit(f"could not attach to launch.py via {CDP_URL}: {e}")
|
||||
pages = [pg for c in browser.contexts for pg in c.pages]
|
||||
if not pages:
|
||||
browser.close()
|
||||
p.stop()
|
||||
sys.exit("no open pages")
|
||||
return p, browser, pages[-1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--name", default="peek", help="screenshot name prefix")
|
||||
ap.add_argument("--json", action="store_true", help="JSON output only")
|
||||
args = ap.parse_args()
|
||||
|
||||
SCREENSHOT_DIR.mkdir(exist_ok=True)
|
||||
p, browser, page = attach_page()
|
||||
try:
|
||||
try:
|
||||
telemetry = page.evaluate(TELEMETRY_JS)
|
||||
except Exception as e: # noqa: BLE001
|
||||
telemetry = {"ready": False, "error": str(e)}
|
||||
|
||||
shot = SCREENSHOT_DIR / f"{int(time.time())}_{args.name}.png"
|
||||
try:
|
||||
page.screenshot(path=str(shot), full_page=False)
|
||||
except Exception as e: # noqa: BLE001
|
||||
shot = None
|
||||
print(f"[!] screenshot failed: {e}", file=sys.stderr)
|
||||
|
||||
result = {
|
||||
"url": page.url,
|
||||
"screenshot": str(shot) if shot else None,
|
||||
**telemetry,
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(f"URL: {result['url']}")
|
||||
print(f"SHOT: {result.get('screenshot')}")
|
||||
print(f"READY: {result.get('ready')}")
|
||||
if result.get("ready"):
|
||||
print(f"TIME: {result.get('scene_time'):.2f}s")
|
||||
pos = result.get("position") or []
|
||||
print(f"POS: ({', '.join(f'{v:.2f}' for v in pos)})")
|
||||
ang = result.get("camera_angles") or []
|
||||
print(f"YAW/PITCH: ({', '.join(f'{v:.3f}' for v in ang)})")
|
||||
print(f"HP: {result.get('hp')}")
|
||||
print(f"ALIVE: {result.get('alive')}")
|
||||
finally:
|
||||
browser.close()
|
||||
p.stop()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
2
test/requirements.txt
Normal file
2
test/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
playwright>=1.40
|
||||
PyYAML>=6.0
|
||||
196
test/run.py
Normal file
196
test/run.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""
|
||||
Run a declarative scenario YAML against the running launch.py browser.
|
||||
|
||||
A scenario is a YAML mapping with a `name`, `description`, and a list
|
||||
of `steps`. Each step is one mapping like `{eval: "..."}`. See
|
||||
test/README.md for the full grammar.
|
||||
|
||||
Usage:
|
||||
python3 run.py scenarios/lighting-times-of-day.yaml
|
||||
python3 run.py scenarios/sunset.yaml --halt-on-fail
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SCREENSHOT_DIR = HERE / "screenshots"
|
||||
CDP_URL = "http://localhost:9222"
|
||||
|
||||
# All keys we'll let scenarios send via page.keyboard.press. Mirrors
|
||||
# cucucaracha's whitelist + the keys our game listens for.
|
||||
KEY_ALLOWLIST = {
|
||||
"Enter", "Escape", "Tab", "Space",
|
||||
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
|
||||
"KeyW", "KeyA", "KeyS", "KeyD",
|
||||
"ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight",
|
||||
"Digit0", "Digit1", "Digit2", "Digit3", "Digit4",
|
||||
"Digit5", "Digit6", "Digit7", "Digit8", "Digit9",
|
||||
}
|
||||
|
||||
|
||||
def attach_page():
|
||||
p = sync_playwright().start()
|
||||
try:
|
||||
browser = p.chromium.connect_over_cdp(CDP_URL)
|
||||
except Exception as e: # noqa: BLE001
|
||||
p.stop()
|
||||
sys.exit(f"could not attach to launch.py via {CDP_URL}: {e}")
|
||||
pages = [pg for c in browser.contexts for pg in c.pages]
|
||||
if not pages:
|
||||
browser.close()
|
||||
p.stop()
|
||||
sys.exit("no open pages")
|
||||
return p, browser, pages[-1]
|
||||
|
||||
|
||||
def step_wait_for(page, expr: str, timeout_ms: int = 30_000):
|
||||
"""Block until `expr` evaluates truthy in the page."""
|
||||
start = time.time()
|
||||
while True:
|
||||
try:
|
||||
ok = page.evaluate(f"() => Boolean({expr})")
|
||||
except Exception:
|
||||
ok = False
|
||||
if ok:
|
||||
return
|
||||
if (time.time() - start) * 1000 > timeout_ms:
|
||||
raise TimeoutError(f"wait_for {expr!r} timed out after {timeout_ms} ms")
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def run_step(page, step: dict, scenario_name: str, halt_on_fail: bool) -> bool:
|
||||
"""Execute one step. Returns False on assertion failure."""
|
||||
if "wait_for" in step:
|
||||
print(f" wait_for: {step['wait_for']}")
|
||||
step_wait_for(page, step["wait_for"], step.get("timeout_ms", 30_000))
|
||||
return True
|
||||
|
||||
if "wait" in step:
|
||||
ms = int(step["wait"])
|
||||
print(f" wait: {ms} ms")
|
||||
time.sleep(ms / 1000.0)
|
||||
return True
|
||||
|
||||
if "eval" in step:
|
||||
print(f" eval: {step['eval']}")
|
||||
# Wrap so a bare expression like `set_scene_time(0)` works too.
|
||||
js = step["eval"]
|
||||
page.evaluate(f"() => {{ {js}; return null; }}")
|
||||
return True
|
||||
|
||||
if "key" in step:
|
||||
key = step["key"]
|
||||
if key not in KEY_ALLOWLIST:
|
||||
raise ValueError(f"key {key!r} not in allowlist {sorted(KEY_ALLOWLIST)}")
|
||||
hold = step.get("hold")
|
||||
if hold:
|
||||
print(f" key: {key} hold={hold}ms")
|
||||
page.keyboard.down(key)
|
||||
time.sleep(int(hold) / 1000.0)
|
||||
page.keyboard.up(key)
|
||||
else:
|
||||
print(f" key: {key}")
|
||||
page.keyboard.press(key)
|
||||
return True
|
||||
|
||||
if "mouse_move" in step:
|
||||
dx, dy = step["mouse_move"]
|
||||
print(f" mouse_move: ({dx}, {dy})")
|
||||
# Relative motion via DeviceEvent — Playwright doesn't expose
|
||||
# raw relative deltas, so we move from center by (dx, dy).
|
||||
# The game uses pointer lock which means we'd need a different
|
||||
# path for real-relative; this still works for many scenarios.
|
||||
page.mouse.move(640 + dx, 400 + dy)
|
||||
return True
|
||||
|
||||
if "mouse" in step:
|
||||
action = step["mouse"]
|
||||
print(f" mouse: {action}")
|
||||
if action == "down":
|
||||
page.mouse.down()
|
||||
elif action == "up":
|
||||
page.mouse.up()
|
||||
elif action == "click":
|
||||
page.mouse.click(640, 400)
|
||||
else:
|
||||
raise ValueError(f"unknown mouse action: {action}")
|
||||
return True
|
||||
|
||||
if "screenshot" in step:
|
||||
SCREENSHOT_DIR.mkdir(exist_ok=True)
|
||||
name = step["screenshot"]
|
||||
out = SCREENSHOT_DIR / f"{scenario_name}_{name}"
|
||||
page.screenshot(path=str(out), full_page=False)
|
||||
print(f" screenshot → {out}")
|
||||
return True
|
||||
|
||||
if "assert" in step:
|
||||
expr = step["assert"]
|
||||
ok = page.evaluate(f"() => Boolean({expr})")
|
||||
symbol = "✓" if ok else "✗"
|
||||
print(f" assert: {expr} {symbol}")
|
||||
if not ok and halt_on_fail:
|
||||
return False
|
||||
return ok
|
||||
|
||||
raise ValueError(f"unknown step type in {step!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("scenario", help="path to scenario YAML")
|
||||
ap.add_argument(
|
||||
"--halt-on-fail",
|
||||
action="store_true",
|
||||
help="stop on first failed assert (default: continue)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
scenario_path = Path(args.scenario)
|
||||
if not scenario_path.exists():
|
||||
sys.exit(f"scenario not found: {scenario_path}")
|
||||
scenario = yaml.safe_load(scenario_path.read_text())
|
||||
name = scenario.get("name") or scenario_path.stem
|
||||
print(f"# scenario: {name}")
|
||||
if scenario.get("description"):
|
||||
print(f"# {scenario['description']}")
|
||||
print()
|
||||
|
||||
p, browser, page = attach_page()
|
||||
failures = 0
|
||||
try:
|
||||
for i, step in enumerate(scenario.get("steps", [])):
|
||||
print(f"step {i}:")
|
||||
try:
|
||||
ok = run_step(page, step, name, args.halt_on_fail)
|
||||
if not ok:
|
||||
failures += 1
|
||||
if args.halt_on_fail:
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" [!] step error: {e}", file=sys.stderr)
|
||||
failures += 1
|
||||
if args.halt_on_fail:
|
||||
break
|
||||
print()
|
||||
if failures == 0:
|
||||
print(f"# {name}: OK ({len(scenario.get('steps', []))} steps)")
|
||||
else:
|
||||
print(f"# {name}: FAILED ({failures} step(s))", file=sys.stderr)
|
||||
finally:
|
||||
browser.close()
|
||||
p.stop()
|
||||
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
40
test/scenarios/god-rays-look-at-sun.yaml
Normal file
40
test/scenarios/god-rays-look-at-sun.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: god-rays-look-at-sun
|
||||
description: |
|
||||
Verify the screen-space god-rays kick in when the camera is
|
||||
pointed roughly toward the sun and there's geometry partially
|
||||
occluding it. Screenshots at three sun altitudes for visual
|
||||
inspection.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
|
||||
|
||||
- eval: "window.voxel_game.set_time_scale(0.0)"
|
||||
|
||||
# Stand on the surface and look in the sun's general direction.
|
||||
# Sun rotates in the xy-plane with z-tilt; at t=60 the sun is
|
||||
# toward the west-and-up. The look_at yaw puts the camera facing
|
||||
# roughly that bearing.
|
||||
- eval: "window.voxel_game.teleport(0, 40, 0)"
|
||||
- eval: "window.voxel_game.look_at(0.0, 0.25)"
|
||||
- wait: 200
|
||||
|
||||
# Mid-morning — sun well above horizon, full daylight, shafts subtle.
|
||||
- eval: "window.voxel_game.set_scene_time(30.0)"
|
||||
- wait: 300
|
||||
- screenshot: 01-mid-morning.png
|
||||
|
||||
# Late afternoon — sun lower, warm light, shafts more pronounced.
|
||||
- eval: "window.voxel_game.set_scene_time(60.0)"
|
||||
- wait: 300
|
||||
- screenshot: 02-late-afternoon.png
|
||||
|
||||
# Sunset — sun at horizon, maximum atmospheric scattering, shafts
|
||||
# should be very prominent.
|
||||
- eval: "window.voxel_game.set_scene_time(75.0)"
|
||||
- wait: 300
|
||||
- screenshot: 03-sunset-rays.png
|
||||
|
||||
# Night — no shafts at all.
|
||||
- eval: "window.voxel_game.set_scene_time(150.0)"
|
||||
- wait: 300
|
||||
- screenshot: 04-night-no-rays.png
|
||||
54
test/scenarios/lighting-times-of-day.yaml
Normal file
54
test/scenarios/lighting-times-of-day.yaml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: lighting-times-of-day
|
||||
description: |
|
||||
Screenshot the same view at noon, golden hour, sunset, civil
|
||||
twilight, and midnight. Used to verify the sunset-family fixes
|
||||
(zenith warm tint, fog warm tint, star fade, sun disc spread).
|
||||
Position the player high above terrain looking toward the horizon
|
||||
so all four faces of nearby blocks (top, two sides, bottom) are
|
||||
visible in the frame.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
|
||||
|
||||
# Freeze time so set_scene_time isn't immediately overwritten.
|
||||
- eval: "window.voxel_game.set_time_scale(0.0)"
|
||||
|
||||
# Move to a fixed vantage point and look out toward the horizon.
|
||||
- eval: "window.voxel_game.teleport(0, 50, 0)"
|
||||
- eval: "window.voxel_game.look_at(0.0, -0.15)"
|
||||
|
||||
# Wait a frame for the camera + body to settle.
|
||||
- wait: 200
|
||||
|
||||
# ---- Sweep through the day/night cycle. DAY_PERIOD = 300s, so:
|
||||
# t = 0 → noon (sun straight up)
|
||||
# t = 75 → sunset (sun at the horizon, west)
|
||||
# t = 150 → midnight (sun straight down)
|
||||
# t = 225 → sunrise
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(0.0)"
|
||||
- wait: 300
|
||||
- screenshot: 01-noon.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(45.0)"
|
||||
- wait: 300
|
||||
- screenshot: 02-afternoon.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(75.0)"
|
||||
- wait: 300
|
||||
- screenshot: 03-sunset.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(90.0)"
|
||||
- wait: 300
|
||||
- screenshot: 04-civil-twilight.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(150.0)"
|
||||
- wait: 300
|
||||
- screenshot: 05-midnight.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(225.0)"
|
||||
- wait: 300
|
||||
- screenshot: 06-sunrise.png
|
||||
|
||||
# Telemetry sanity: after all that, time should match what we set.
|
||||
- assert: "Math.abs(window.voxel_game.get_scene_time() - 225.0) < 2.0"
|
||||
32
test/scenarios/voxel-construction-darkness.yaml
Normal file
32
test/scenarios/voxel-construction-darkness.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: voxel-construction-darkness
|
||||
description: |
|
||||
Build a sealed shelter around the player at the surface, then
|
||||
compare the lighting inside vs. an open-air view. The sky-vis
|
||||
bake (sim::lighting::compute_ambience) should produce darker
|
||||
interior even though it's noon — depends only on the surrounding
|
||||
voxel construction.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
|
||||
- eval: "window.voxel_game.set_time_scale(0.0)"
|
||||
- eval: "window.voxel_game.set_scene_time(0.0)"
|
||||
|
||||
# Establish the open-air baseline at the surface.
|
||||
- eval: "window.voxel_game.teleport(0, 35, 0)"
|
||||
- eval: "window.voxel_game.look_at(0.0, 0.0)"
|
||||
- wait: 300
|
||||
- screenshot: 01-open-air-baseline.png
|
||||
|
||||
# Look straight up — should see bright blue sky (sky_vis = 1, full
|
||||
# daylight) bordered by nearby block sides at lower sky_vis.
|
||||
- eval: "window.voxel_game.look_at(0.0, 1.4)"
|
||||
- wait: 300
|
||||
- screenshot: 02-looking-up.png
|
||||
|
||||
# Now point at a side face of nearby terrain to see how block
|
||||
# sides read at noon (the historical "dark sides" bug).
|
||||
- eval: "window.voxel_game.look_at(0.0, -0.1)"
|
||||
- wait: 300
|
||||
- screenshot: 03-side-faces-at-noon.png
|
||||
|
||||
- assert: "Math.abs(window.voxel_game.get_scene_time() - 0.0) < 2.0"
|
||||
Loading…
Add table
Reference in a new issue