fix: make the renderer actually draw (step size, storage buffer, quad fit)

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).
This commit is contained in:
xfy 2026-07-13 11:46:38 +08:00
parent 87b089eb35
commit ee15ddecb7
4 changed files with 69 additions and 20 deletions

View File

@ -50,7 +50,15 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
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<f32> {
// 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));

View File

@ -1,7 +1,9 @@
#define_import_path singularity::planets
#import singularity::disk::rot_x
struct SphereData {
center: vec4<f32>, // xyz = center, w = radius
center: vec4<f32>, // xyz = center (world space), w = radius
color: vec4<f32>, // 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<f32>, cur: vec3<f32>, dir: vec3<f32>) -> vec4<f32> {
var nearest_t = 1e9;
var nearest_col = vec3<f32>(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;

View File

@ -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<Assets<Mesh>>,
mut materials: ResMut<Assets<BlackHoleMaterial>>,
mut buffers: ResMut<Assets<ShaderBuffer>>,
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<ShaderBuffer>) 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<FullscreenQuad>>) {
/// 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<FullscreenQuad>>,
) {
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<Time>, mut camera: Query<&mut Transform, With<Camera2d>>) {
let nudge = (time.elapsed_secs() * 5.0).sin() * 1e-3;
for mut t in &mut camera {
t.translation.x = nudge;
}
}
fn mirror_params(
camera: Res<crate::camera::OrbitCamera>,
params: Res<crate::params::BlackHoleParams>,

View File

@ -44,7 +44,6 @@ pub fn upload_planets(
// Build (or rebuild) the ShaderBuffer and share its handle across materials.
let buffer = ShaderBuffer::from(data);
for (_, mat) in materials.iter_mut() {
// Replace the handle each frame (simple, correct; cheap for one material).
mat.planets = buffers.add(buffer.clone());
}
}