Compare commits

...

5 commits

Author SHA1 Message Date
Maximus Gorog
f1e007dd63 Test harness: declarative Playwright scenarios + wasm state bindings
Mirrors the cucucaracha (lacucarachanews) toolkit pattern adapted for
the voxel game:

bridges.rs adds TestCommand + Telemetry plumbing:
  - thread_local TEST_COMMANDS queue + TELEMETRY snapshot.
  - drain_test_commands() called by App::tick at frame start.
  - publish_telemetry(t) called at frame end.
  - wasm_api exports: set_scene_time, teleport, look_at, plus
    getters get_scene_time / get_position / get_camera_angles.

app.rs:
  - drain_test_commands() applies SetSceneTime / Teleport / LookAt
    before physics integrates. Teleport zeroes velocity and syncs the
    camera to feet+EYE_HEIGHT.
  - publish_telemetry() at end of tick exposes scene state to JS.

test/:
  launch.py     Open Chromium with persistent profile + CDP:9222.
                Navigates to https://voxel.mxvs.art by default;
                --url for local dev.
  peek.py       Attach via CDP, screenshot canvas, dump telemetry
                (scene_time, position, camera angles, hp). Read-only.
  run.py        Execute a YAML scenario:
                  wait_for, wait, eval, key, mouse_move, mouse,
                  screenshot, assert
                Key allowlist prevents stray scenarios from sending
                arbitrary input.
  requirements.txt   playwright + PyYAML.
  README.md          Setup, grammar, available bindings, why this
                     exists.
  scenarios/
    lighting-times-of-day.yaml   Screenshots at noon / afternoon /
                                  sunset / civil twilight / midnight
                                  / sunrise. Verifies the Round A
                                  sunset fixes by visual diff.
    god-rays-look-at-sun.yaml    Pointed at the sun at four altitudes
                                  to inspect the Round B shafts.
    voxel-construction-darkness.yaml  Visual baseline for the sky_vis
                                  bake from Round D.
  .gitignore                     Excludes the browser profile +
                                  screenshots directory.

Visual regression workflow:
  1. python3 launch.py
  2. (separate terminal) python3 run.py scenarios/lighting-times-of-day.yaml
  3. Compare screenshots/lighting-times-of-day_*.png against baseline.

Tests still 63 passing. Native + wasm release clean.
2026-05-24 10:51:17 -06:00
Maximus Gorog
dccb06dddf Round D: bake bounce_color + material per vertex; specular for ice/snow (#7, #9, #15, #17 foundation)
sim/lighting.rs:
  - New compute_ambience(world, pos, normal) -> VertexAmbience
    returns BOTH sky_vis AND bounce_color from the same 8-ray
    hemisphere cast. No double walks of the voxel grid.
  - walks_until_solid(world, origin, dir) -> Option<Vec3> is the
    primitive: None = escaped to sky, Some(color) = first solid hit.
    Used by compute_ambience to accumulate the bounce_color average.
  - sky_visibility is now a thin wrapper around compute_ambience for
    tests and clarity.

world.rs:
  - Block::average_color() returns the mean across all 6 faces — the
    contribution this block makes to a neighbor's bounce when hit by
    a hemisphere ray.
  - Block::material_id() returns 0 (matte) for most blocks, 1
    (specular) for Ice + Snow. Reserved 2 for future emissive blocks.

mesh.rs:
  - Vertex grows `bounce_mat: [f32; 4]` packing baked bounce color
    (rgb) + material id (a). Float32x4 attribute at @location(6).
  - build_chunk_mesh now calls compute_ambience for each quad corner
    (same one-pass ray-cast that produced sky_vis already, plus the
    bounce accumulation). Material id taken from cell.block.

shader.wgsl:
  - VsIn / VsOut grow bounce_mat (vec4).
  - ambient_term now takes the per-vertex bounce_albedo instead of a
    hard-coded gray-brown. A red brick wall thus casts a faint red
    bounce on dirt next to it — real GI hint, baked once at mesh
    time, zero cost at runtime per fragment.
  - material_for(id) lookup: Phong specular for ice/snow (m.specular
    = 0.45 with cos⁴⁸ half-vector); emission slot reserved.
  - fs_main pipeline now includes the specular + emission terms.
  - Camera.frame keeps Round C accessor names; ambient_strength
    untouched.

Tests: 63 passing. Native + wasm release clean.

Deferred from this round (own sessions): #18 water (new pipeline +
animation), #19 transparent/cutout (new blend state), #8 wider AO
(current 4-corner is sufficient for now; the wider-occlusion effect
is now coming through bounce_color anyway).

Visual change: bounce-tinted ambient (red walls glow red onto
neighbors); ice + snow get specular highlight in direct sun.
2026-05-24 10:24:59 -06:00
Maximus Gorog
0dca49f475 Round C: typed camera accessors, post-pass helper, leaf translucency (#20, #23, #25)
shader.wgsl:
  - Camera.frame layout now documented slot-by-slot in the struct
    comment; named accessors scene_time() / exposure_bias() / eye_world()
    so call sites read as intent. exposure_bias plumbed (default 1.0)
    as the foundation for a future Settings.exposure slider. [#20]
  - Leaves now get sun-aware backlit translucency: leaf_translucency()
    peaks when the sun rakes across the leaf plane AND comes from
    behind the surface. Cheap stand-in for subsurface scattering —
    reads as "dappled sun through canopy" without per-pixel ray
    sampling. [#25]

render/mod.rs:
  - run_fullscreen_pass(encoder, label, target, pipeline, bgs, clear)
    helper. The three post-chain steps (mask → shafts → composite)
    are now three sequential calls instead of three copies of the
    same begin_render_pass boilerplate. Adding a new effect (bloom,
    motion blur, vignette) is one extra row. [#23]
  - upload_camera fills frame[1] = 1.0 (exposure_bias default).

render/uniform.rs:
  - CameraUniform.frame doc updated to enumerate each slot.

Tests: 63 passing. Native + wasm release clean.
2026-05-24 10:16:05 -06:00
Maximus Gorog
bd6b3fadb0 Round B: screen-space god rays + FXAA (#12, #14)
New WGSL files:
  mask.wgsl     Sun-cone × sky-alpha mask at ¼ resolution. Marks sky
                pixels (scene_color.a == 0) that fall within a tight
                cone around the sun direction. Sun direction derived
                from scene time via the injected DAY_PERIOD /
                SUN_OFFSET constants — same source as sim::lighting,
                so visual rays align with the mechanical sun.
  shafts.wgsl   Radial blur at ¼ resolution. Projects the sun
                direction-at-infinity to screen space via view_proj,
                steps 32 samples from each pixel toward the
                sun_screen_pos accumulating mask intensity with
                exponential decay, outputs sun-tinted ray color.

shader.wgsl:
  - fs_sky now writes alpha = 0 so the mask pass can identify sky
    pixels without a separate occluder pass. Terrain / outline /
    remote-player pipelines continue writing alpha = 1.

post.wgsl (rewritten):
  - Reads scene_color + shafts.
  - Cheap edge-aware FXAA (5-tap diagonal blur, blends toward neighbor
    average where luminance gradient exceeds threshold). Catches the
    axis-aligned staircase aliasing that voxel games produce.
  - Adds shafts additively before tonemap so rays go through the
    filmic curve and don't blow out.

render/scene_target.rs:
  - create_image_bind_group (single texture + sampler) — used by
    mask pass (binds scene_color) and shafts pass (binds mask_view).
  - create_composite_bind_group (scene + shafts + sampler) — used by
    the final post pass.
  - create_quarter_res_view (¹⁄₁₆ fillrate, RENDER_ATTACHMENT +
    TEXTURE_BINDING) — used for both mask and shafts targets.

render/pipelines.rs:
  - image_bgl / composite_bgl as separate layouts.
  - fullscreen_pipeline factory replaces the post-specific one;
    takes vs/fs entry-point names so mask, shafts, and post all
    build through the same shape.

render/mod.rs:
  - Renderer grows mask_view, shafts_view, image_bgl, composite_bgl,
    mask_pipeline, shafts_pipeline, mask_bg, shafts_bg fields. The
    old single-pass post_pipeline becomes the final composite pass.
  - render() now does scene → mask → shafts → post (each in its own
    encoder block).
  - resize() recreates all three render targets and all three bind
    groups in the right order.

Tests: 63 passing (added 2 for mask/shafts source assembly). Native
+ wasm release clean.

Visual change: when you face roughly toward the sun and there's
geometry blocking the disc, you'll see warm light shafts radiating
outward. FXAA softens the worst pixel-step edges. Tonemap (from
Round A) is now the final step of the new pipeline.
2026-05-24 10:09:10 -06:00
Maximus Gorog
94585b1ab2 Round A: sunset family + ACES tonemap (#1-4, #6)
shader.wgsl:
  - sky_dome: zenith now tints toward a dusky-purple zenith_twi during
    twilight (was previously only the horizon picking up the warm tint).
    Top faces at sunset will read warm through the sky_vis pathway
    instead of staying cold blue. [#1]
  - apply_fog: at twilight the fog tints toward sun_tint by twi×0.45,
    so distant terrain reads warm against an orange sky instead of
    cold. [#2]
  - sky_color stars: gate flipped from (1 - day) — which still showed
    stars during dusk while the sky was bright — to a direct
    smoothstep on sun.y (-0.22..0.04). Stars now fade in only after
    civil twilight. [#3]
  - sun disc / halo: sharpness + intensity now altitude-dependent. At
    zenith it's a sharp pinpoint (sharpness 800, intensity 1.5); near
    the horizon it softens to a big atmospheric bloom (sharpness 160,
    intensity 2.2). Halo also widens and brightens at low sun. [#4]

post.wgsl:
  - ACES filmic tonemap (Narkowicz approximation) runs as the final
    step. Brightens midtones, compresses highlights smoothly into
    [0,1] instead of the previous hard clamp. Output stays linear; the
    sRGB surface handles display encoding. Foundation for everything
    HDR that follows (god rays, bloom). [#6]
2026-05-24 00:20:25 -06:00
23 changed files with 1577 additions and 148 deletions

View file

@ -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;
}

View file

@ -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
View 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);
}

View file

@ -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]);

View file

@ -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);
}

View file

@ -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 {

View file

@ -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(),
}),

View file

@ -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())
}

View file

@ -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],
}

View file

@ -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_vis1 full bright sky-color ambient.
/// - Deep cave at noon: sky_vis0 tiny sun-bounce only.
/// - Player-built roof: sky_vis0 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_vis1 full bright sky-color ambient
/// - Deep cave at noon: sky_vis0 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 ----------------

View file

@ -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
View 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);
}

View file

@ -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 {

View file

@ -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
View file

@ -0,0 +1,5 @@
.browser_profile/
screenshots/
.venv/
__pycache__/
*.pyc

102
test/README.md Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
playwright>=1.40
PyYAML>=6.0

196
test/run.py Normal file
View 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())

View 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

View 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"

View 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"