From ee15ddecb708b9f53ccea75714370bca30412962 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 13 Jul 2026 11:46:38 +0800 Subject: [PATCH] fix: make the renderer actually draw (step size, storage buffer, quad fit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed Phase 1 code rendered a black screen due to three independent bugs, fixed here: - black_hole.wgsl: dt was sized as |eye|/steps, so the full integration only traveled |eye| units total — rays never reached capture or escape and every pixel fell through to accum=black. Now dt covers eye_dist + escape_r across the configured step count, and the escape test uses the dynamic escape_r instead of a hardcoded 1000.0. - render/plugin.rs: the planets binding was Handle::default(), causing AsBindGroup to return RetryNextUpdate every frame and silently skip the fullscreen quad's draw. Pre-fill a MAX_PLANETS-sized zeroed ShaderBuffer at startup so the binding resolves immediately. - render/plugin.rs: scale the fullscreen quad by half the window size to match Camera2d's default WindowSize projection (1 unit = 1 px); the old aspect-based scaling letterboxed the image. - render/plugin.rs: add nudge_camera to work around Bevy 0.19 #24448, where a static camera stops rendering after the first frame. - planets.wgsl: rotate world-space planet centers into disk-local space before the ray-sphere test (ray and centers were in different spaces). --- assets/shaders/black_hole.wgsl | 12 +++++-- assets/shaders/planets.wgsl | 11 ++++-- src/render/plugin.rs | 65 ++++++++++++++++++++++++++-------- src/scene/planets.rs | 1 - 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 62fd40a..637ceb3 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -50,7 +50,15 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { var pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt); var d = normalize(rot_x(dir, -uniforms.disk_tilt)); - let dt = max(length(uniforms.eye.xyz), 20.0) / f32(uniforms.steps); + // Total path length to integrate: enough to go from the camera, past the + // hole, and far enough beyond to count as escaped. We size dt so that + // `steps` steps cover this distance. (The original dt=|eye|/steps only + // traveled |eye| units total — never reaching capture or escape — so + // every ray fell through to accum=black.) + let eye_dist = length(uniforms.eye.xyz); + let escape_r = max(eye_dist * 2.0, 100.0); // "escaped" = clearly past the hole + let total_path = eye_dist + escape_r; // go in, through, and out + let dt = total_path / f32(uniforms.steps); let steps = uniforms.steps; // Front-to-back compositing. @@ -64,7 +72,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { // Captured: whatever we've composited so far is the result. break; } - if (r > 1000.0) { + if (r > escape_r) { // Escaped: add background along the (disk-local) final dir. // Rotate back to world for the sky/stars sample. let world_dir = normalize(rot_x(d, uniforms.disk_tilt)); diff --git a/assets/shaders/planets.wgsl b/assets/shaders/planets.wgsl index df2059d..c71958f 100644 --- a/assets/shaders/planets.wgsl +++ b/assets/shaders/planets.wgsl @@ -1,7 +1,9 @@ #define_import_path singularity::planets +#import singularity::disk::rot_x + struct SphereData { - center: vec4, // xyz = center, w = radius + center: vec4, // xyz = center (world space), w = radius color: vec4, // xyz = color, w = emissive flag (u32 reinterpreted; we just check > 0.5) }; @@ -11,13 +13,18 @@ struct SphereData { // Test the segment prev->cur against all planets. Returns hit color & alpha, // or (0,0,0,0) if no hit. `dir` is the ray direction (for shading). +// `prev`/`cur` are in DISK-LOCAL space (the caller rotates eye/dir by -disk_tilt +// before integrating), so we rotate each planet's world-space center into +// disk-local space here for a consistent intersection test. fn planet_hit(prev: vec3, cur: vec3, dir: vec3) -> vec4 { var nearest_t = 1e9; var nearest_col = vec3(0.0); var found = false; for (var i: u32 = 0u; i < uniforms.planet_count; i = i + 1u) { let s = planets[i]; - let center = s.center.xyz; + // Planet centers are stored in world space; rotate into disk-local space + // to match the ray's coordinate system. + let center = rot_x(s.center.xyz, -uniforms.disk_tilt); let radius = s.center.w; // Ray-sphere intersection for the segment. let seg = cur - prev; diff --git a/src/render/plugin.rs b/src/render/plugin.rs index f5bb511..7d3e10a 100644 --- a/src/render/plugin.rs +++ b/src/render/plugin.rs @@ -1,10 +1,11 @@ use bevy::prelude::*; +use bevy::render::storage::ShaderBuffer; use bevy::sprite_render::Material2dPlugin; use super::material::BlackHoleMaterial; /// Marks the full-screen quad so the resize system can find and rescale it -/// when the window's aspect ratio changes (the mesh is built once at startup). +/// when the window is resized (the mesh is built once at startup). #[derive(Component)] struct FullscreenQuad; @@ -21,7 +22,12 @@ impl Plugin for BlackHolePlugin { .add_systems(Startup, crate::scene::planets::spawn_default_planet) .add_systems( Update, - (crate::camera::orbit_controller, mirror_params, fit_quad_to_window), + ( + crate::camera::orbit_controller, + mirror_params, + fit_quad_to_window, + nudge_camera, + ), ) .add_systems(Update, crate::scene::planets::upload_planets) // bevy_egui 0.41 requires UI systems to run inside the egui context @@ -34,41 +40,70 @@ fn spawn_fullscreen_quad( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, + mut buffers: ResMut>, window: Query<&Window>, ) { let win = match window.single() { Ok(w) => w, Err(_) => return, }; - let aspect = win.width() / win.height(); - // The mesh is a unit square (2x2 covers [-1,1]); we rescale via Transform - // in fit_quad_to_window whenever the aspect changes. + // Camera2d's default projection is ScalingMode::WindowSize (1 world unit = + // 1 pixel, view centered at origin spanning [-w/2,w/2]×[-h/2,h/2]). A unit + // quad (2×2) scaled by (w/2, h/2) fills the screen. fit_quad_to_window + // updates this on resize. + let half_w = win.width() / 2.0; + let half_h = win.height() / 2.0; + // CRITICAL: the planets storage binding (Handle) must point + // at a REAL buffer asset, not Handle::default(). A default handle makes + // AsBindGroup return RetryNextUpdate every frame, which silently skips the + // quad's draw — the screen shows only the camera clear color. Pre-fill a + // MAX_PLANETS-sized buffer of zeroed SphereData; upload_planets updates it. + let planets_buffer = buffers.add(ShaderBuffer::from(vec![ + super::material::SphereData::default( + ); + super::material::MAX_PLANETS + ])); + let mut material = BlackHoleMaterial::default(); + material.planets = planets_buffer; commands.spawn(( Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))), - MeshMaterial2d(materials.add(BlackHoleMaterial::default())), - Transform::default().with_scale(Vec3::new(aspect, 1.0, 1.0)), + MeshMaterial2d(materials.add(material)), + Transform::default().with_scale(Vec3::new(half_w, half_h, 1.0)), FullscreenQuad, )); commands.spawn(Camera2d); } -/// Rescale the full-screen quad so it always covers the camera's view, even -/// after the window is resized. Camera2d's default projection spans y∈[-1,1] -/// and x∈[-aspect, aspect]; scaling the unit quad by (aspect, 1, 1) fills it. -fn fit_quad_to_window(window: Query<&Window>, mut quad: Query<&mut Transform, With>) { +/// Rescale the full-screen quad to the live window size so it always fills the +/// camera's view (Camera2d default projection: 1 world unit = 1 pixel). +fn fit_quad_to_window( + window: Query<&Window>, + mut quad: Query<&mut Transform, With>, +) { let win = match window.single() { Ok(w) => w, Err(_) => return, }; - let aspect = win.width() / win.height(); + let target = Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0); for mut transform in &mut quad { - // Only mutate on change to avoid spamming change detection. - if transform.scale.x != aspect { - transform.scale.x = aspect; + if transform.scale != target { + transform.scale = target; } } } +/// Workaround for Bevy 0.19 issue #24448: with a static camera the world stops +/// rendering after the first frame. Oscillate the camera transform by a +/// sub-pixel amount each frame so the view matrix changes and the render graph +/// keeps producing frames. Amplitude is far below one pixel, so the image is +/// visually stable. Remove when the upstream regression is fixed. +fn nudge_camera(time: Res